use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use axum::Router;
use axum::extract::{Query, State};
use axum::response::Html;
use axum::routing::get;
use chrono::{TimeDelta, Utc};
use openidconnect::core::{CoreAuthenticationFlow, CoreClient};
use openidconnect::{
AuthorizationCode, CsrfToken, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier,
RedirectUrl, Scope, TokenResponse,
};
use secrecy::SecretString;
use serde::Deserialize;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use super::{OidcConfig, OidcTokens};
pub const DEFAULT_CALLBACK_PORT: u16 = 5555;
const FLOW_TIMEOUT: Duration = Duration::from_secs(180);
pub async fn run(cfg: &OidcConfig, http: &reqwest::Client, port: u16) -> Result<OidcTokens> {
let metadata = super::discovery::load_provider_metadata(cfg, http).await?;
let listener = bind_callback_listener(port).await?;
let redirect_uri = RedirectUrl::new(format!("http://127.0.0.1:{port}/callback"))
.context("building redirect URI")?;
let client = CoreClient::from_provider_metadata(metadata, cfg.client_id.clone(), None)
.set_redirect_uri(redirect_uri.clone());
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let mut authorize_req = client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.set_pkce_challenge(pkce_challenge);
for scope in &cfg.scopes {
authorize_req = authorize_req.add_scope(Scope::new(scope.clone()));
}
if let Some(audience) = cfg.audience.as_deref() {
authorize_req = authorize_req.add_extra_param("audience", audience);
}
let (auth_url, csrf_token, _nonce) = authorize_req.url();
let (tx, rx) = oneshot::channel::<Result<CallbackData>>();
let server_state = CallbackState {
tx: Arc::new(std::sync::Mutex::new(Some(tx))),
expected_state: csrf_token.secret().clone(),
};
let app = Router::new()
.route("/callback", get(callback_handler))
.with_state(server_state);
let server_task = tokio::spawn(async move {
let _ = axum::serve(listener, app.into_make_service()).await;
});
eprintln!("Opening browser for login: {}", auth_url);
if webbrowser::open(auth_url.as_str()).is_err() {
eprintln!(
"Could not open a browser automatically. Open this URL manually:\n {}",
auth_url
);
}
let callback = tokio::select! {
result = rx => result.context("callback channel closed")??,
_ = tokio::signal::ctrl_c() => {
server_task.abort();
bail!("login cancelled");
}
_ = tokio::time::sleep(FLOW_TIMEOUT) => {
server_task.abort();
bail!("timed out waiting for browser login after {}s", FLOW_TIMEOUT.as_secs());
}
};
server_task.abort();
let token_response = client
.exchange_code(AuthorizationCode::new(callback.code))
.context("building token exchange request")?
.set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier.secret().clone()))
.request_async(http)
.await
.map_err(explain_token_error)?;
Ok(tokens_from_response(&token_response))
}
fn explain_token_error<RE, T>(err: openidconnect::RequestTokenError<RE, T>) -> anyhow::Error
where
RE: std::error::Error + 'static,
T: openidconnect::ErrorResponse + 'static,
{
use openidconnect::RequestTokenError as E;
match err {
E::Parse(parse_err, body) => {
let preview = String::from_utf8_lossy(&body);
let preview = if preview.len() > 4096 {
format!(
"{}… (truncated, {} bytes total)",
&preview[..4096],
body.len()
)
} else {
preview.into_owned()
};
anyhow!(
"token endpoint returned a body openidconnect could not parse: {parse_err}\n\
--- response body ({} bytes) ---\n{preview}\n--- end ---",
body.len()
)
}
E::ServerResponse(srv) => anyhow!("token endpoint rejected the request: {srv}"),
E::Request(re) => anyhow!("token endpoint request failed: {re}"),
E::Other(s) => anyhow!("token exchange: {s}"),
}
}
#[derive(Clone)]
struct CallbackState {
tx: Arc<std::sync::Mutex<Option<oneshot::Sender<Result<CallbackData>>>>>,
expected_state: String,
}
#[derive(Deserialize)]
struct CallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
struct CallbackData {
code: String,
}
async fn callback_handler(
State(state): State<CallbackState>,
Query(query): Query<CallbackQuery>,
) -> Html<&'static str> {
let outcome: Result<CallbackData> = match query {
CallbackQuery {
error: Some(error),
error_description: desc,
..
} => {
let detail = desc.unwrap_or_default();
Err(anyhow!("IdP returned error `{error}`: {detail}"))
}
CallbackQuery {
code: Some(code),
state: Some(received_state),
..
} if received_state == state.expected_state => Ok(CallbackData { code }),
CallbackQuery { state: Some(_), .. } => Err(anyhow!(
"callback state did not match — possible CSRF; refusing the response"
)),
_ => Err(anyhow!("callback missing `code` or `state`")),
};
let success = outcome.is_ok();
if let Some(tx) = state.tx.lock().unwrap().take() {
let _ = tx.send(outcome);
}
if success {
Html(SUCCESS_HTML)
} else {
Html(FAILURE_HTML)
}
}
async fn bind_callback_listener(port: u16) -> Result<TcpListener> {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
TcpListener::bind(addr).await.map_err(|e| {
anyhow!(
"binding callback listener on 127.0.0.1:{port}: {e}\n\
Free the port (e.g. `lsof -i :{port}`) or pass `--callback-port <PORT>` and add the \
matching `http://127.0.0.1:<PORT>/callback` to the IdP's allow-list."
)
})
}
fn tokens_from_response(resp: &openidconnect::core::CoreTokenResponse) -> OidcTokens {
let expires_in = resp
.expires_in()
.and_then(|d| TimeDelta::from_std(d).ok())
.unwrap_or(TimeDelta::minutes(5));
OidcTokens {
access_token: SecretString::from(resp.access_token().secret().clone()),
refresh_token: resp
.refresh_token()
.map(|t| SecretString::from(t.secret().clone())),
id_token: resp.id_token().map(|t| SecretString::from(t.to_string())),
expires_at: Utc::now() + expires_in,
}
}
const SUCCESS_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>BoxLite — Login complete</title>
<style>
body{font-family:'IBM Plex Mono',ui-monospace,'SFMono-Regular','SF Mono',monospace;
font-size:13px;line-height:1.45;
display:flex;align-items:center;justify-content:center;
height:100vh;margin:0;background:#13161B;color:#FFFFFF}
.card{background:#1A1D24;border:1px solid #2A2F3A;padding:32px;text-align:center;max-width:400px}
h1{margin:0 0 8px;font-size:16px;font-weight:500;letter-spacing:-0.025em}
p{margin:0;color:#8C919C}
</style>
</head>
<body>
<div class="card">
<h1>Login complete</h1>
<p>You can close this tab and return to your terminal.</p>
</div>
</body>
</html>"#;
const FAILURE_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>BoxLite — Login failed</title>
<style>
body{font-family:'IBM Plex Mono',ui-monospace,'SFMono-Regular','SF Mono',monospace;
font-size:13px;line-height:1.45;
display:flex;align-items:center;justify-content:center;
height:100vh;margin:0;background:#13161B;color:#FFFFFF}
.card{background:#1A1D24;border:1px solid #2A2F3A;padding:32px;text-align:center;max-width:400px}
h1{margin:0 0 8px;font-size:16px;font-weight:500;letter-spacing:-0.025em;color:#E05548}
p{margin:0;color:#8C919C}
</style>
</head>
<body>
<div class="card">
<h1>Login failed</h1>
<p>Return to your terminal — the CLI has the details.</p>
</div>
</body>
</html>"#;
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn binds_loopback_and_redirect_url_round_trips() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind ephemeral");
let port = listener.local_addr().expect("addr").port();
drop(listener);
let listener = bind_callback_listener(port)
.await
.expect("rebind exact port");
assert_eq!(listener.local_addr().unwrap().port(), port);
let url = format!("http://127.0.0.1:{port}/callback");
RedirectUrl::new(url).expect("valid redirect URL");
}
#[tokio::test]
async fn second_bind_on_same_port_errors_with_actionable_message() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind ephemeral");
let port = listener.local_addr().expect("addr").port();
let err = bind_callback_listener(port)
.await
.expect_err("second bind should fail");
let msg = err.to_string();
assert!(
msg.contains("--callback-port"),
"actionable hint missing: {msg}"
);
}
#[test]
fn default_callback_port_is_stable() {
assert_eq!(DEFAULT_CALLBACK_PORT, 5555);
}
}