use std::path::PathBuf; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::json; use thiserror::Error; use tracing::{debug, error}; use uuid::Uuid; use crate::cache; #[derive(Error, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ErrorPayload { pub error: String, pub cause: Option, pub error_message: String, } impl std::fmt::Display for ErrorPayload { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", self.error_message) } } #[derive(Debug, Error)] pub enum YggdrasilAuthError { #[error("Error sending HTTP request to authserver: {0}")] HttpError(#[from] reqwest::Error), #[error("This account has been migrated")] Migrated, #[error("Forbidden operation: {0}")] ForbiddenOperation(ErrorPayload), #[error("Unauthorized: {0}")] Unauthorized(ErrorPayload), #[error("RateLimiter disallowed request")] RateLimited, #[error("Error reading password: {0}")] PasswordError(#[from] std::io::Error), #[error("Unexpected response from authserver (status code {status_code}): {body}")] UnexpectedResponse { status_code: u16, body: String }, } #[derive(Debug, Deserialize)] pub struct Property { pub name: String, pub value: String, } #[derive(Debug, Deserialize)] pub struct User { pub id: Uuid, pub username: Option, pub properties: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct YggdrasilAuthResult { pub access_token: String, pub client_token: String, pub user: User, } #[derive(Debug, Serialize, Deserialize)] pub struct YggdrasilCachedAccount { pub backend: String, pub username: String, pub access_token: String, pub client_token: String, } pub struct YggdrasilCacheKey<'a> { pub backend: &'a str, pub username: &'a str, } impl<'a, 'b> PartialEq> for YggdrasilCacheKey<'a> { fn eq(&self, other: &YggdrasilCacheKey<'b>) -> bool { self.backend == other.backend && self.username == other.username } } impl cache::CacheEntry for YggdrasilCachedAccount { type Key<'a> = YggdrasilCacheKey<'a>; fn key<'a>(&'a self) -> Self::Key<'a> { YggdrasilCacheKey { backend: &self.backend, username: &self.username, } } } #[derive(Default, Debug, Clone)] pub struct YggdrasilAuthOpts { /// The directory to store the cache in. /// /// If this is `None`, azalea-auth will not keep its own cache. pub cache_file: Option, } async fn handle_response( res: reqwest::Response, ) -> Result { match res.status() { StatusCode::OK => Ok(res.json::().await?), StatusCode::FORBIDDEN => Err(YggdrasilAuthError::ForbiddenOperation( res.json::().await?, )), StatusCode::UNAUTHORIZED => Err(YggdrasilAuthError::Unauthorized( res.json::().await?, )), StatusCode::GONE => Err(YggdrasilAuthError::Migrated), StatusCode::TOO_MANY_REQUESTS => Err(YggdrasilAuthError::RateLimited), status_code => { // log the headers debug!("Error headers: {:#?}", res.headers()); let body = res.text().await?; Err(YggdrasilAuthError::UnexpectedResponse { status_code: status_code.as_u16(), body, }) } } } pub async fn authenticate( username: &str, password: &str, backend: &str, ) -> Result { let data = json!({ "password": password, "username": username, "requestUser": true, }); handle_response( reqwest::ClientBuilder::new() .build()? .post(format!("{backend}/authenticate")) .json(&data) .send() .await?, ) .await } pub async fn refresh( access_token: &str, client_token: &str, backend: &str, ) -> Result { let data = json!({ "accessToken": access_token, "clientToken": client_token, "requestUser": true, }); handle_response( reqwest::ClientBuilder::new() .build()? .post(format!("{backend}/refresh")) .json(&data) .send() .await?, ) .await } async fn new_session( username: &str, password: Option<&str>, backend: &str, ) -> Result { let password = match password { Some(x) => x, None => &rpassword::prompt_password(format!("Enter password for {username}: "))?, }; authenticate(username, password, backend).await } pub async fn yggdrasil_auth( username: &str, password: Option<&str>, backend: &str, opts: YggdrasilAuthOpts, ) -> Result { let cached_account = if let Some(cache_file) = &opts.cache_file { cache::get_account_in_cache_g::( cache_file, YggdrasilCacheKey { username, backend }, ) .await } else { None }; let result = match cached_account { Some(acc) => match refresh(&acc.access_token, &acc.client_token, backend).await { Ok(x) => x, Err(e) => { error!("While refreshing {}: {}", username, e); new_session(username, password, backend).await? } }, None => new_session(username, password, backend).await?, }; if let Some(cache_file) = &opts.cache_file && let Err(e) = cache::set_account_in_cache_g::( cache_file, YggdrasilCacheKey { username, backend }, YggdrasilCachedAccount { backend: backend.to_owned(), username: username.to_owned(), access_token: result.access_token.clone(), client_token: result.client_token.clone(), }, ) .await { error!("{}", e); } Ok(result) }