use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse};
use axum::routing::get;
use parking_lot::Mutex;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::time::timeout;
use super::OAuthError;
#[derive(Debug, Clone)]
pub struct LoopbackOutcome {
pub code: String,
pub state: String,
}
pub struct LoopbackHandle {
pub redirect_url: String,
shutdown: oneshot::Sender<()>,
receiver: oneshot::Receiver<Result<LoopbackOutcome, OAuthError>>,
}
pub struct LoopbackBinding {
pub redirect_url: String,
shutdown: oneshot::Sender<()>,
receiver: oneshot::Receiver<Result<LoopbackOutcome, OAuthError>>,
expected_state: Arc<Mutex<Option<String>>>,
}
impl LoopbackBinding {
pub fn expect_state(self, expected_state: String) -> LoopbackHandle {
*self.expected_state.lock() = Some(expected_state);
LoopbackHandle {
redirect_url: self.redirect_url,
shutdown: self.shutdown,
receiver: self.receiver,
}
}
}
impl LoopbackHandle {
pub async fn wait(self, deadline: Duration) -> Result<LoopbackOutcome, OAuthError> {
let LoopbackHandle {
shutdown,
receiver,
redirect_url: _,
} = self;
let outcome = match timeout(deadline, receiver).await {
Ok(Ok(outcome)) => outcome,
Ok(Err(_)) => Err(OAuthError::CallbackParse("receiver closed".into())),
Err(_) => Err(OAuthError::Timeout),
};
let _ = shutdown.send(());
outcome
}
}
type ResultSender = oneshot::Sender<Result<LoopbackOutcome, OAuthError>>;
#[derive(Clone)]
struct CallbackState {
expected_state: Arc<Mutex<Option<String>>>,
result_tx: Arc<Mutex<Option<ResultSender>>>,
}
async fn callback_handler(
State(state): State<CallbackState>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let Some(expected_state) = state.expected_state.lock().clone() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Html("<h1>Authorization callback is not ready</h1>".to_string()),
);
};
let tx = state.result_tx.lock().take();
match (
params.get("code"),
params.get("state"),
params.get("error"),
tx,
) {
(_, _, Some(err), Some(tx)) => {
let err = if err == "access_denied" {
OAuthError::UserDenied
} else {
OAuthError::CallbackParse(format!("provider error: {err}"))
};
let _ = tx.send(Err(err));
(
StatusCode::BAD_REQUEST,
Html("<h1>Authorization error</h1>".to_string()),
)
}
(Some(code), Some(actual_state), _, Some(tx)) => {
if actual_state == &expected_state {
let _ = tx.send(Ok(LoopbackOutcome {
code: code.clone(),
state: actual_state.clone(),
}));
(
StatusCode::OK,
Html(
"<h1>Authorization complete</h1><p>You may close this window.</p>"
.to_string(),
),
)
} else {
let _ = tx.send(Err(OAuthError::StateMismatch));
(
StatusCode::BAD_REQUEST,
Html("<h1>State mismatch</h1>".to_string()),
)
}
}
_ => (
StatusCode::BAD_REQUEST,
Html("<h1>Invalid callback</h1>".to_string()),
),
}
}
pub async fn run_loopback_callback(
expected_state: String,
path: &str,
) -> Result<LoopbackHandle, OAuthError> {
Ok(bind_loopback_callback(path)
.await?
.expect_state(expected_state))
}
pub async fn bind_loopback_callback(path: &str) -> Result<LoopbackBinding, OAuthError> {
bind_loopback_callback_with_redirect(path, "127.0.0.1", &[0]).await
}
pub async fn bind_loopback_callback_with_redirect(
path: &str,
redirect_host: &str,
preferred_ports: &[u16],
) -> Result<LoopbackBinding, OAuthError> {
let (result_tx, result_rx) = oneshot::channel();
let expected_state = Arc::new(Mutex::new(None));
let state = CallbackState {
expected_state: Arc::clone(&expected_state),
result_tx: Arc::new(Mutex::new(Some(result_tx))),
};
let app = Router::new()
.route(path, get(callback_handler))
.with_state(state);
let ports = if preferred_ports.is_empty() {
&[0][..]
} else {
preferred_ports
};
let mut last_error = None;
let mut listener = None;
for port in ports {
let bind_addr = format!("127.0.0.1:{port}");
match TcpListener::bind(&bind_addr).await {
Ok(bound) => {
listener = Some(bound);
break;
}
Err(err) => {
last_error = Some(format!("{bind_addr}: {err}"));
}
}
}
let listener = listener.ok_or_else(|| {
OAuthError::InvalidConfig(format!(
"bind: {}",
last_error.unwrap_or_else(|| "no callback ports configured".to_string())
))
})?;
let addr = listener
.local_addr()
.map_err(|e| OAuthError::InvalidConfig(format!("addr: {e}")))?;
let redirect_url = format!("http://{}:{}{}", redirect_host, addr.port(), path);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
Ok(LoopbackBinding {
redirect_url,
shutdown: shutdown_tx,
receiver: result_rx,
expected_state,
})
}