From 871ef4abac1023d33714d2fda9c54cc63e20a3a5 Mon Sep 17 00:00:00 2001 From: Lizzy Fleckenstein Date: Sun, 10 May 2026 16:48:07 +0200 Subject: support yggdrasil authentication --- azalea-auth/src/auth.rs | 2 + azalea-auth/src/lib.rs | 2 + azalea-auth/src/yggdrasil.rs | 226 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 azalea-auth/src/yggdrasil.rs diff --git a/azalea-auth/src/auth.rs b/azalea-auth/src/auth.rs index ef084d95..c2b639e9 100644 --- a/azalea-auth/src/auth.rs +++ b/azalea-auth/src/auth.rs @@ -60,6 +60,8 @@ pub enum AuthError { GetMinecraftAuthToken(#[from] MinecraftAuthError), #[error("Error authenticating with Xbox Live: {0}")] GetXboxLiveAuth(#[from] XboxLiveAuthError), + #[error("Error authenticating with Yggdrasil")] + Yggdrasil(#[from] crate::yggdrasil::YggdrasilAuthError), } /// Authenticate with Microsoft. If the data isn't cached, the user will be diff --git a/azalea-auth/src/lib.rs b/azalea-auth/src/lib.rs index 6cbee318..dadd3bbc 100644 --- a/azalea-auth/src/lib.rs +++ b/azalea-auth/src/lib.rs @@ -9,6 +9,8 @@ pub mod certs; #[cfg(feature = "online-mode")] pub mod sessionserver; #[cfg(feature = "online-mode")] +pub mod yggdrasil; +#[cfg(feature = "online-mode")] pub use auth::*; pub mod game_profile; diff --git a/azalea-auth/src/yggdrasil.rs b/azalea-auth/src/yggdrasil.rs new file mode 100644 index 00000000..7eaa0589 --- /dev/null +++ b/azalea-auth/src/yggdrasil.rs @@ -0,0 +1,226 @@ +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) +} -- cgit v1.2.3