aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLizzy Fleckenstein <lizzy@vlhl.dev>2026-05-10 16:48:07 +0200
committerLizzy Fleckenstein <lizzy@vlhl.dev>2026-05-10 16:48:07 +0200
commit871ef4abac1023d33714d2fda9c54cc63e20a3a5 (patch)
tree434144cf5041a8a3d9e014b8a95724fb7d822e4c
parent3d452c73c1d53099c8dc2d0e34634bf52262347d (diff)
downloadazalea-drasl-871ef4abac1023d33714d2fda9c54cc63e20a3a5.tar.xz
support yggdrasil authentication
-rw-r--r--azalea-auth/src/auth.rs2
-rw-r--r--azalea-auth/src/lib.rs2
-rw-r--r--azalea-auth/src/yggdrasil.rs226
3 files changed, 230 insertions, 0 deletions
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<String>,
+ 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<String>,
+ pub properties: Vec<Property>,
+}
+
+#[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<YggdrasilCacheKey<'b>> 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<PathBuf>,
+}
+
+async fn handle_response(
+ res: reqwest::Response,
+) -> Result<YggdrasilAuthResult, YggdrasilAuthError> {
+ match res.status() {
+ StatusCode::OK => Ok(res.json::<YggdrasilAuthResult>().await?),
+ StatusCode::FORBIDDEN => Err(YggdrasilAuthError::ForbiddenOperation(
+ res.json::<ErrorPayload>().await?,
+ )),
+ StatusCode::UNAUTHORIZED => Err(YggdrasilAuthError::Unauthorized(
+ res.json::<ErrorPayload>().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<YggdrasilAuthResult, YggdrasilAuthError> {
+ 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<YggdrasilAuthResult, YggdrasilAuthError> {
+ 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<YggdrasilAuthResult, YggdrasilAuthError> {
+ 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<YggdrasilAuthResult, YggdrasilAuthError> {
+ let cached_account = if let Some(cache_file) = &opts.cache_file {
+ cache::get_account_in_cache_g::<YggdrasilCachedAccount>(
+ 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::<YggdrasilCachedAccount>(
+ 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)
+}