use std::{
collections::HashMap,
sync::mpsc::{self, Sender},
thread,
};
use actix_web::{
get,
web::{self, Data},
App, HttpResponse, HttpServer,
};
use oauth2::{
basic::BasicClient,
http::{
header::{ACCEPT, CONTENT_TYPE},
HeaderMap, HeaderValue, StatusCode,
},
AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl,
Scope, TokenUrl,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{error::result::HttpClientResult, http_client_bail, HttpClientError};
#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone)]
pub struct Oauth2LoginConfig {
pub client_id: String,
pub client_secret: String,
pub authorize_url: String,
pub token_url: String,
pub scopes: Vec<String>,
}
pub struct LoginState {
login_config: Oauth2LoginConfig,
redirect_url: Url,
pub auth_url: Url,
pkce_verifier: PkceCodeVerifier,
csrf_token: CsrfToken,
}
impl TryFrom<Oauth2LoginConfig> for LoginState {
type Error = HttpClientError;
fn try_from(login_config: Oauth2LoginConfig) -> Result<Self, Self::Error> {
let mut redirect_url = Url::parse("http://localhost:17899/authorization")?;
if let Ok(port_s) = std::env::var("OAUTH2_REDIRECT_URL_PORT") {
let port = port_s.parse::<u16>().map_err(|e| {
HttpClientError::Default(format!("Invalid OAUTH2_REDIRECT_URL_PORT: {e:?}"))
})?;
redirect_url.set_port(Some(port)).map_err(|e| {
HttpClientError::Default(format!("Invalid OAUTH2_REDIRECT_URL_PORT: {e:?}"))
})?;
}
let client = BasicClient::new(ClientId::new(login_config.client_id.clone()))
.set_client_secret(ClientSecret::new(login_config.client_secret.clone()))
.set_auth_uri(AuthUrl::new(login_config.authorize_url.clone())?)
.set_token_uri(TokenUrl::new(login_config.token_url.clone())?)
.set_redirect_uri(RedirectUrl::new(redirect_url.to_string())?);
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let scopes = login_config
.scopes
.iter()
.map(|s| Scope::new(s.clone()))
.collect::<Vec<_>>();
let auth_request = client
.authorize_url(CsrfToken::new_random)
.add_scopes(scopes)
.set_pkce_challenge(pkce_challenge);
let (auth_url, csrf_token) = auth_request.url();
Ok(Self {
login_config,
redirect_url,
auth_url,
pkce_verifier,
csrf_token,
})
}
}
impl LoginState {
pub async fn finalize(&self) -> HttpClientResult<String> {
let auth_parameters = Self::receive_authorization_parameters()?;
let received_state = auth_parameters.get("state").ok_or_else(|| {
HttpClientError::Default("state not received on authentication".to_owned())
})?;
if received_state != self.csrf_token.secret() {
return Err(HttpClientError::Default(
"state received on authentication does not match".to_owned(),
));
}
let authorization_code = auth_parameters.get("code").ok_or_else(|| {
HttpClientError::Default("code not received on authentication".to_owned())
})?;
let token_result = request_token(
&self.login_config,
&self.redirect_url,
&self.pkce_verifier,
authorization_code,
)
.await?;
Ok(match token_result.id_token {
Some(id_token) => id_token,
None => token_result.access_token,
})
}
#[allow(clippy::unwrap_used)]
fn receive_authorization_parameters() -> HttpClientResult<HashMap<String, String>> {
let (auth_params_tx, auth_params_rx) = mpsc::channel::<HashMap<String, String>>();
let tokio_handle = tokio::runtime::Handle::current();
let _task = thread::spawn(move || {
tokio_handle.block_on({
#[get("/authorization")]
async fn authorization_handler(
auth_params: web::Query<HashMap<String, String>>,
auth_params_tx: Data<Sender<HashMap<String, String>>>,
) -> HttpResponse {
auth_params_tx
.into_inner()
.send(auth_params.into_inner())
.unwrap();
HttpResponse::Ok().body("You can now close this window.")
}
HttpServer::new(move || {
App::new()
.app_data(Data::new(auth_params_tx.clone()))
.service(authorization_handler)
})
.bind(("127.0.0.1", 17899))?
.run()
})
});
auth_params_rx.recv().map_err(|e| {
HttpClientError::Default(format!("authorization code not received: {e:?}"))
})
}
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)]
pub(crate) struct OAuthResponse {
pub access_token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token: Option<String>,
#[serde(skip)]
pub expires_in: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_type: Option<String>,
}
pub(crate) async fn request_token(
login_config: &Oauth2LoginConfig,
redirect_url: &Url,
pkce_verifier: &PkceCodeVerifier,
authorization_code: &str,
) -> HttpClientResult<OAuthResponse> {
let params = vec![
("grant_type", "authorization_code"),
("redirect_uri", redirect_url.as_str()),
("client_id", login_config.client_id.as_str()),
("code", authorization_code),
("client_secret", login_config.client_secret.as_str()),
("code_verifier", pkce_verifier.secret()),
];
let mut headers = HeaderMap::new();
headers.append(ACCEPT, HeaderValue::from_static("application/json"));
headers.append(
CONTENT_TYPE,
HeaderValue::from_static("application/x-www-form-urlencoded"),
);
let body = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(params)
.finish();
let client = reqwest::Client::new();
let response = client
.post(&login_config.token_url)
.header(ACCEPT, "application/json")
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.map_err(|e| {
HttpClientError::Default(format!("failed issuing token exchange request: {e:?}"))
})?;
if response.status() != StatusCode::OK {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "<failed to read response>".to_owned());
http_client_bail!("failed token exchange: {error_text}")
}
response.json().await.map_err(|e| {
HttpClientError::Default(format!("failed parsing token exchange response: {e:?}"))
})
}