use std::{net::SocketAddr, sync::Arc, time::Duration};
use async_trait::async_trait;
use axum::{
Router,
extract::{Query, State},
response::Html,
routing::get,
};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::TcpListener,
sync::{Mutex, oneshot},
time::timeout,
};
use tracing::{debug, error, info};
const BROWSER_CALLBACK_HTML: &str = include_str!("res/default_callback.html");
#[async_trait]
pub trait AuthHandler: Debug {
fn redirect_uri(&self) -> &str;
async fn authenticate(&self, auth_url: &str) -> Result<AuthGrant, AuthFlowError>;
}
#[derive(Debug, thiserror::Error)]
#[error("initializing authentication handler failed: {msg}")]
pub struct AuthInitError {
msg: String,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
}
#[derive(Debug, thiserror::Error)]
pub enum AuthFlowError {
#[error("callback response malformed or incomplete: {msg}")]
BadResponse {
msg: String,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("authentication aborted: {msg}")]
Aborted {
msg: String,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("authentication process failed with internal error: {msg}")]
Internal {
msg: String,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthGrant {
pub code: String,
pub state: String,
}
#[derive(Debug)]
pub struct BrowserAuth {
listen_addr: String,
cb_tx: Arc<Mutex<Option<oneshot::Sender<AuthGrant>>>>,
auth_timeout: Duration,
}
#[derive(Clone)]
struct BrowserAuthAppState {
cb_tx: Arc<Mutex<Option<oneshot::Sender<AuthGrant>>>>,
}
async fn browser_callback_handler(
Query(params): Query<AuthGrant>,
State(state): State<BrowserAuthAppState>,
) -> Html<String> {
debug!("Received callback: {params:?}");
if let Some(cb_tx) = state.cb_tx.lock().await.take() {
cb_tx.send(params).ok();
}
Html(BROWSER_CALLBACK_HTML.to_string())
}
impl BrowserAuth {
pub const DEFAULT_TIMEOUT: Duration = Duration::from_mins(2);
pub async fn new() -> Result<Self, AuthInitError> {
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
let listener = TcpListener::bind(addr).await.map_err(|e| AuthInitError {
msg: "failed to bind callback server to a random port".to_string(),
source: Some(e.into()),
})?;
let addr = listener.local_addr().map_err(|e| AuthInitError {
msg: "failed to get address of callback server".to_string(),
source: Some(e.into()),
})?;
let listen_addr = format!("http://localhost:{}/callback", addr.port());
let cb_rx = Arc::new(Mutex::new(None));
let app_state = BrowserAuthAppState {
cb_tx: cb_rx.clone(),
};
let app = Router::new()
.route("/callback", get(browser_callback_handler))
.with_state(app_state);
info!("Listening for callback at: {}", listen_addr);
tokio::spawn(async move {
let result = axum::serve(listener, app).await;
if let Err(e) = result {
error!("callback server error: {e}");
}
});
Ok(BrowserAuth {
listen_addr,
cb_tx: cb_rx,
auth_timeout: Self::DEFAULT_TIMEOUT,
})
}
#[must_use]
pub fn with_timeout(self, timeout: Duration) -> Self {
Self {
auth_timeout: timeout,
..self
}
}
}
#[async_trait]
impl AuthHandler for BrowserAuth {
fn redirect_uri(&self) -> &str {
&self.listen_addr
}
async fn authenticate(&self, auth_url: &str) -> Result<AuthGrant, AuthFlowError> {
let (cb_tx, cb_rx) = oneshot::channel();
*self.cb_tx.lock().await = Some(cb_tx);
info!("Opening authorization page in browser: {auth_url}");
webbrowser::open(auth_url).map_err(|e| AuthFlowError::Internal {
msg: "failed to open the authentication URL in a web browser".to_string(),
source: Some(e.into()),
})?;
info!(
"Waiting {} seconds for browser callback",
self.auth_timeout.as_secs()
);
let auth_result =
timeout(self.auth_timeout, cb_rx)
.await
.map_err(|_| AuthFlowError::Aborted {
msg: "timeout while waiting for the user to authenticate".to_string(),
source: None,
})?;
let params = auth_result.map_err(|e| AuthFlowError::Internal {
msg: "failed to receive parameters over callback channel".to_string(),
source: Some(e.into()),
})?;
info!("Authentication completed.");
Ok(params)
}
}
#[derive(Debug)]
pub struct ConsoleAuth {}
impl Default for ConsoleAuth {
fn default() -> Self {
Self::new()
}
}
impl ConsoleAuth {
const REDIRECT_BASE_URL: &str = "http://localhost:7878/callback";
#[must_use]
pub fn new() -> Self {
ConsoleAuth {}
}
}
#[async_trait]
impl AuthHandler for ConsoleAuth {
fn redirect_uri(&self) -> &str {
Self::REDIRECT_BASE_URL
}
async fn authenticate(&self, auth_url: &str) -> Result<AuthGrant, AuthFlowError> {
eprintln!("\nOpen the following URL in your browser to authenticate:\n");
eprintln!(" {auth_url}\n");
eprintln!("After completing authentication, your browser will redirect to a URL");
eprintln!(
"starting with '{}?...' (the page won't load).",
Self::REDIRECT_BASE_URL
);
eprint!("\nPaste that full redirect URL here: ");
tokio::io::stdout().flush().await.ok();
let mut line = String::new();
BufReader::new(tokio::io::stdin())
.read_line(&mut line)
.await
.map_err(|e| AuthFlowError::Internal {
msg: "failed to read redirect URL from stdin".to_string(),
source: Some(e.into()),
})?;
let redirect_url = line.trim().to_string();
let parsed = Url::parse(&redirect_url).map_err(|e| AuthFlowError::BadResponse {
msg: format!("invalid redirect URL '{redirect_url}'"),
source: Some(e.into()),
})?;
let grant = serde_urlencoded::from_str(parsed.query().unwrap_or("")).map_err(|e| {
AuthFlowError::BadResponse {
msg: format!("failed to parse query parameters from redirect URL '{redirect_url}'"),
source: Some(e.into()),
}
})?;
Ok(grant)
}
}