Skip to main content

protocol_droid/adapters/secondary/oauth/
browser.rs

1//! A user agent for a person with a browser.
2//!
3//! [`LoopbackBrowser`] opens the authorization URL in the system browser and
4//! waits for the authorization server to send the browser back to a loopback
5//! address, which is how RFC 8252 has a native app receive an authorization
6//! response.
7//!
8//! The redirect URI has to be known before the client registers, so the
9//! listener is bound first and the caller reads [`redirect_uri`] off it:
10//!
11//! ```no_run
12//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
13//! use protocol_droid::adapters::secondary::oauth::LoopbackBrowser;
14//! use protocol_droid::application::oauth::authorizer::OAuthConfig;
15//! use protocol_droid::domain::ProtocolVersion;
16//!
17//! let browser = LoopbackBrowser::bind().await?;
18//! let config = OAuthConfig::new("https://mcp.example.com/mcp", ProtocolVersion::V2025_11_25)?
19//!     .redirect_to(browser.redirect_uri());
20//! # let _ = config;
21//! # Ok(())
22//! # }
23//! ```
24//!
25//! [`redirect_uri`]: LoopbackBrowser::redirect_uri
26//!
27//! The listener accepts connections from this machine only, answers a single
28//! path, reads at most a few kilobytes of any request, and treats anything it
29//! cannot parse as not the redirect and keeps waiting. The `state` check that
30//! ties a response to the request that started it happens in the flow, not
31//! here: this type delivers whatever arrives at the callback path.
32
33use core::future::Future;
34use core::pin::Pin;
35use core::time::Duration;
36use std::sync::Arc;
37
38use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
39use tokio::net::TcpListener;
40
41use crate::adapters::secondary::oauth::environment::{UserAgent, callback_from};
42use crate::application::oauth::callback::AuthorizationCallback;
43use crate::application::oauth::environment::EnvironmentError;
44
45/// The path the authorization server is sent back to.
46const CALLBACK_PATH: &str = "/callback";
47
48/// The most of a request this listener reads before giving up on it.
49const MAX_REQUEST: usize = 8 * 1024;
50
51/// How long one connection may take to send its request line.
52const READ_TIMEOUT: Duration = Duration::from_secs(5);
53
54/// How long the user has to finish at the authorization server.
55const DEFAULT_PATIENCE: Duration = Duration::from_secs(300);
56
57const PAGE: &str = "<!doctype html><title>Signed in</title>\
58<p>You can close this tab and return to the terminal.</p>";
59
60/// Opens a URL for the user.
61type Opener = Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>;
62
63/// Receives an authorization response on `127.0.0.1`.
64pub struct LoopbackBrowser {
65    listener: TcpListener,
66    redirect_uri: String,
67    patience: Duration,
68    open: Opener,
69}
70
71impl core::fmt::Debug for LoopbackBrowser {
72    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
73        f.debug_struct("LoopbackBrowser")
74            .field("redirect_uri", &self.redirect_uri)
75            .finish_non_exhaustive()
76    }
77}
78
79impl LoopbackBrowser {
80    /// Binds a loopback port chosen by the operating system.
81    ///
82    /// # Errors
83    ///
84    /// [`EnvironmentError::Unavailable`] when no port could be bound.
85    pub async fn bind() -> Result<Self, EnvironmentError> {
86        let listener = TcpListener::bind(("127.0.0.1", 0))
87            .await
88            .map_err(|error| EnvironmentError::Unavailable(error.to_string()))?;
89        let port = listener
90            .local_addr()
91            .map_err(|error| EnvironmentError::Unavailable(error.to_string()))?
92            .port();
93        Ok(Self {
94            listener,
95            redirect_uri: format!("http://127.0.0.1:{port}{CALLBACK_PATH}"),
96            patience: DEFAULT_PATIENCE,
97            open: Arc::new(open_in_browser),
98        })
99    }
100
101    /// The URI to register as this client's redirect.
102    #[must_use]
103    pub fn redirect_uri(&self) -> &str {
104        &self.redirect_uri
105    }
106
107    /// How long to wait for the user. Defaults to five minutes.
108    #[must_use]
109    pub const fn waiting(mut self, patience: Duration) -> Self {
110        self.patience = patience;
111        self
112    }
113
114    /// Replaces how the URL is shown to the user, for a headless machine or a
115    /// test. The default starts the system browser.
116    #[must_use]
117    pub fn opening_with(
118        mut self,
119        open: impl Fn(&str) -> Result<(), String> + Send + Sync + 'static,
120    ) -> Self {
121        self.open = Arc::new(open);
122        self
123    }
124
125    /// Accepts connections until one is the redirect.
126    async fn await_redirect(&self) -> Result<AuthorizationCallback, EnvironmentError> {
127        loop {
128            let (mut stream, peer) = self
129                .listener
130                .accept()
131                .await
132                .map_err(|error| EnvironmentError::Declined(error.to_string()))?;
133            if !peer.ip().is_loopback() {
134                continue;
135            }
136            let Some(target) = read_target(&mut stream).await else {
137                respond(&mut stream, "400 Bad Request", "").await;
138                continue;
139            };
140            let (path, _) = target.split_once('?').unwrap_or((target.as_str(), ""));
141            if path != CALLBACK_PATH {
142                respond(&mut stream, "404 Not Found", "").await;
143                continue;
144            }
145            // Parsed against the loopback origin: only the query matters.
146            let callback = callback_from(&format!("http://127.0.0.1{target}"));
147            respond(&mut stream, "200 OK", PAGE).await;
148            return callback;
149        }
150    }
151}
152
153impl UserAgent for LoopbackBrowser {
154    fn authorize<'a>(
155        &'a self,
156        authorization_url: &'a str,
157    ) -> Pin<Box<dyn Future<Output = Result<AuthorizationCallback, EnvironmentError>> + Send + 'a>>
158    {
159        Box::pin(async move {
160            // The URL comes from a document the authorization server served, so
161            // only a web URL is handed to the operating system.
162            if !(authorization_url.starts_with("https://")
163                || authorization_url.starts_with("http://"))
164            {
165                return Err(EnvironmentError::Declined(
166                    "the authorization URL is not a web URL".to_owned(),
167                ));
168            }
169            // Always shown, so a machine with no browser can be driven by pasting.
170            eprintln!("Sign in at: {authorization_url}");
171            if let Err(reason) = (self.open)(authorization_url) {
172                eprintln!("(could not start a browser: {reason})");
173            }
174            tokio::time::timeout(self.patience, self.await_redirect())
175                .await
176                .map_err(|_| {
177                    EnvironmentError::Declined("no one completed the sign-in in time".to_owned())
178                })?
179        })
180    }
181}
182
183/// Reads the request target out of the request line, or `None` for anything
184/// that is not a small `GET`.
185async fn read_target(stream: &mut tokio::net::TcpStream) -> Option<String> {
186    let mut buffer = Vec::with_capacity(512);
187    let mut chunk = [0_u8; 512];
188    let read = tokio::time::timeout(READ_TIMEOUT, async {
189        loop {
190            let count = stream.read(&mut chunk).await.ok()?;
191            if count == 0 {
192                return None;
193            }
194            buffer.extend_from_slice(chunk.get(..count)?);
195            if buffer.windows(2).any(|pair| pair == b"\r\n") || buffer.len() > MAX_REQUEST {
196                return Some(());
197            }
198        }
199    })
200    .await;
201    read.ok()??;
202    let line = buffer.split(|byte| *byte == b'\r').next()?;
203    let line = core::str::from_utf8(line).ok()?;
204    let mut parts = line.split(' ');
205    let (method, target) = (parts.next()?, parts.next()?);
206    (method == "GET" && target.starts_with('/')).then(|| target.to_owned())
207}
208
209/// Writes a minimal response and closes. Failures are ignored: the browser
210/// going away after the redirect changes nothing about the answer.
211async fn respond(stream: &mut tokio::net::TcpStream, status: &str, body: &str) {
212    let head = format!(
213        "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\
214         Cache-Control: no-store\r\nConnection: close\r\n\r\n",
215        body.len()
216    );
217    let _ = stream.write_all(head.as_bytes()).await;
218    let _ = stream.write_all(body.as_bytes()).await;
219    let _ = stream.shutdown().await;
220}
221
222/// Starts the platform's URL handler without a shell.
223fn open_in_browser(url: &str) -> Result<(), String> {
224    let mut command = if cfg!(target_os = "macos") {
225        let mut command = std::process::Command::new("open");
226        command.arg(url);
227        command
228    } else if cfg!(windows) {
229        let mut command = std::process::Command::new("rundll32");
230        command.args(["url.dll,FileProtocolHandler", url]);
231        command
232    } else {
233        let mut command = std::process::Command::new("xdg-open");
234        command.arg(url);
235        command
236    };
237    command
238        .stdin(std::process::Stdio::null())
239        .stdout(std::process::Stdio::null())
240        .stderr(std::process::Stdio::null())
241        .spawn()
242        .map(|_| ())
243        .map_err(|error| error.to_string())
244}
245
246#[cfg(test)]
247mod tests;