use core::future::Future;
use core::pin::Pin;
use core::time::Duration;
use std::sync::Arc;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::net::TcpListener;
use crate::adapters::secondary::oauth::environment::{UserAgent, callback_from};
use crate::application::oauth::callback::AuthorizationCallback;
use crate::application::oauth::environment::EnvironmentError;
const CALLBACK_PATH: &str = "/callback";
const MAX_REQUEST: usize = 8 * 1024;
const READ_TIMEOUT: Duration = Duration::from_secs(5);
const DEFAULT_PATIENCE: Duration = Duration::from_secs(300);
const PAGE: &str = "<!doctype html><title>Signed in</title>\
<p>You can close this tab and return to the terminal.</p>";
type Opener = Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>;
pub struct LoopbackBrowser {
listener: TcpListener,
redirect_uri: String,
patience: Duration,
open: Opener,
}
impl core::fmt::Debug for LoopbackBrowser {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("LoopbackBrowser")
.field("redirect_uri", &self.redirect_uri)
.finish_non_exhaustive()
}
}
impl LoopbackBrowser {
pub async fn bind() -> Result<Self, EnvironmentError> {
let listener = TcpListener::bind(("127.0.0.1", 0))
.await
.map_err(|error| EnvironmentError::Unavailable(error.to_string()))?;
let port = listener
.local_addr()
.map_err(|error| EnvironmentError::Unavailable(error.to_string()))?
.port();
Ok(Self {
listener,
redirect_uri: format!("http://127.0.0.1:{port}{CALLBACK_PATH}"),
patience: DEFAULT_PATIENCE,
open: Arc::new(open_in_browser),
})
}
#[must_use]
pub fn redirect_uri(&self) -> &str {
&self.redirect_uri
}
#[must_use]
pub const fn waiting(mut self, patience: Duration) -> Self {
self.patience = patience;
self
}
#[must_use]
pub fn opening_with(
mut self,
open: impl Fn(&str) -> Result<(), String> + Send + Sync + 'static,
) -> Self {
self.open = Arc::new(open);
self
}
async fn await_redirect(&self) -> Result<AuthorizationCallback, EnvironmentError> {
loop {
let (mut stream, peer) = self
.listener
.accept()
.await
.map_err(|error| EnvironmentError::Declined(error.to_string()))?;
if !peer.ip().is_loopback() {
continue;
}
let Some(target) = read_target(&mut stream).await else {
respond(&mut stream, "400 Bad Request", "").await;
continue;
};
let (path, _) = target.split_once('?').unwrap_or((target.as_str(), ""));
if path != CALLBACK_PATH {
respond(&mut stream, "404 Not Found", "").await;
continue;
}
let callback = callback_from(&format!("http://127.0.0.1{target}"));
respond(&mut stream, "200 OK", PAGE).await;
return callback;
}
}
}
impl UserAgent for LoopbackBrowser {
fn authorize<'a>(
&'a self,
authorization_url: &'a str,
) -> Pin<Box<dyn Future<Output = Result<AuthorizationCallback, EnvironmentError>> + Send + 'a>>
{
Box::pin(async move {
if !(authorization_url.starts_with("https://")
|| authorization_url.starts_with("http://"))
{
return Err(EnvironmentError::Declined(
"the authorization URL is not a web URL".to_owned(),
));
}
eprintln!("Sign in at: {authorization_url}");
if let Err(reason) = (self.open)(authorization_url) {
eprintln!("(could not start a browser: {reason})");
}
tokio::time::timeout(self.patience, self.await_redirect())
.await
.map_err(|_| {
EnvironmentError::Declined("no one completed the sign-in in time".to_owned())
})?
})
}
}
async fn read_target(stream: &mut tokio::net::TcpStream) -> Option<String> {
let mut buffer = Vec::with_capacity(512);
let mut chunk = [0_u8; 512];
let read = tokio::time::timeout(READ_TIMEOUT, async {
loop {
let count = stream.read(&mut chunk).await.ok()?;
if count == 0 {
return None;
}
buffer.extend_from_slice(chunk.get(..count)?);
if buffer.windows(2).any(|pair| pair == b"\r\n") || buffer.len() > MAX_REQUEST {
return Some(());
}
}
})
.await;
read.ok()??;
let line = buffer.split(|byte| *byte == b'\r').next()?;
let line = core::str::from_utf8(line).ok()?;
let mut parts = line.split(' ');
let (method, target) = (parts.next()?, parts.next()?);
(method == "GET" && target.starts_with('/')).then(|| target.to_owned())
}
async fn respond(stream: &mut tokio::net::TcpStream, status: &str, body: &str) {
let head = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\
Cache-Control: no-store\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(head.as_bytes()).await;
let _ = stream.write_all(body.as_bytes()).await;
let _ = stream.shutdown().await;
}
fn open_in_browser(url: &str) -> Result<(), String> {
let mut command = if cfg!(target_os = "macos") {
let mut command = std::process::Command::new("open");
command.arg(url);
command
} else if cfg!(windows) {
let mut command = std::process::Command::new("rundll32");
command.args(["url.dll,FileProtocolHandler", url]);
command
} else {
let mut command = std::process::Command::new("xdg-open");
command.arg(url);
command
};
command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map(|_| ())
.map_err(|error| error.to_string())
}
#[cfg(test)]
mod tests;