protocol_droid/adapters/secondary/oauth/
browser.rs1use 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
45const CALLBACK_PATH: &str = "/callback";
47
48const MAX_REQUEST: usize = 8 * 1024;
50
51const READ_TIMEOUT: Duration = Duration::from_secs(5);
53
54const 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
60type Opener = Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>;
62
63pub 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 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 #[must_use]
103 pub fn redirect_uri(&self) -> &str {
104 &self.redirect_uri
105 }
106
107 #[must_use]
109 pub const fn waiting(mut self, patience: Duration) -> Self {
110 self.patience = patience;
111 self
112 }
113
114 #[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 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 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 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 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
183async 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
209async 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
222fn 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;