use crate::ErrorKind; use crate::util::fetch::INSECURE_REQWEST_CLIENT; use crate::util::mojang::{mojang_service_url, should_use_mojang_mirror}; use base64::Engine; use base64::prelude::{BASE64_STANDARD, BASE64_URL_SAFE_NO_PAD}; use chrono::{DateTime, Duration, TimeZone, Utc}; use dashmap::DashMap; use futures::TryStreamExt; use heck::ToTitleCase; use p256::ecdsa::signature::Signer; use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding}; use rand::Rng; use rand::rngs::OsRng; use reqwest::header::HeaderMap; use reqwest::{Response, StatusCode}; use serde::de::DeserializeOwned; use serde::ser::SerializeStruct; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::json; use sha2::Digest; use std::borrow::Cow; use std::collections::HashMap; use std::future::Future; use std::hash::{BuildHasherDefault, DefaultHasher}; use std::io; use std::ops::Deref; use std::sync::Arc; use std::time::Instant; use tokio::runtime::{Handle, RuntimeFlavor}; use tokio::sync::Mutex; use tokio::task; use url::Url; use uuid::Uuid; mod yggdrasil; pub use yggdrasil::*; #[derive(Debug, Clone, Copy)] pub enum MinecraftAuthStep { GetDeviceToken, SisuAuthenticate, GetOAuthToken, RefreshOAuthToken, SisuAuthorize, XstsAuthorize, MinecraftToken, MinecraftEntitlements, MinecraftProfile, } #[derive(thiserror::Error, Debug)] pub enum MinecraftAuthenticationError { #[error("Error reading public key during generation")] ReadingPublicKey, #[error("Failed to serialize private key to PEM: {0}")] PEMSerialize(#[from] p256::pkcs8::Error), #[error("Failed to serialize body to JSON during step {step:?}: {source}")] SerializeBody { step: MinecraftAuthStep, #[source] source: serde_json::Error, }, #[error( "Failed to deserialize response to JSON during step {step:?}: {source}. Status Code: {status_code} Body: {raw}" )] DeserializeResponse { step: MinecraftAuthStep, raw: String, #[source] source: serde_json::Error, status_code: StatusCode, }, #[error("Request failed during step {step:?}: {source}")] Request { step: MinecraftAuthStep, #[source] source: reqwest::Error, }, #[error("Error reading XBOX Session ID header")] NoSessionId, #[error("Error reading user hash")] NoUserHash, #[error("The Microsoft account does not own Minecraft: Java Edition")] NoMinecraftEntitlement, } #[derive(Serialize, Deserialize, Debug)] pub struct MinecraftLoginFlow { pub verifier: String, pub state: String, pub auth_request_uri: String, pub redirect_uri: String, } #[derive(Serialize, Deserialize, Debug)] pub struct MinecraftDeviceLoginFlow { pub device_code: String, pub user_code: String, pub verification_uri: String, pub expires_in: u64, pub interval: u64, } #[derive(Serialize, Debug)] #[serde(tag = "status", rename_all = "snake_case")] pub enum MinecraftDeviceLoginPoll { Pending { slow_down: bool }, Complete { credentials: Credentials }, } #[tracing::instrument] pub async fn login_begin( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result { login_begin_with_redirect(AUTH_REPLY_URL, exec).await } #[tracing::instrument] pub async fn login_begin_with_redirect( redirect_uri: &str, _exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result { let verifier = generate_oauth_challenge(); let state = generate_oauth_challenge(); let result = sha2::Sha256::digest(&verifier); let challenge = BASE64_URL_SAFE_NO_PAD.encode(result); let mut auth_request_uri = Url::parse(MICROSOFT_AUTHORIZE_URL)?; auth_request_uri.query_pairs_mut().extend_pairs([ ("client_id", MICROSOFT_CLIENT_ID), ("response_type", "code"), ("redirect_uri", redirect_uri), ("response_mode", "query"), ("scope", REQUESTED_SCOPE), ("code_challenge", &challenge), ("code_challenge_method", "S256"), ("state", &state), ("prompt", "select_account"), ]); Ok(MinecraftLoginFlow { verifier, state, auth_request_uri: auth_request_uri.into(), redirect_uri: redirect_uri.to_owned(), }) } #[tracing::instrument] pub async fn browser_login_begin( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result { login_begin_with_redirect(BROWSER_AUTH_REPLY_URL, exec).await } #[tracing::instrument] pub async fn device_login_begin() -> crate::Result { let mut query = HashMap::new(); query.insert("client_id", MICROSOFT_CLIENT_ID); query.insert("scope", REQUESTED_SCOPE); let res = auth_retry(|| { INSECURE_REQWEST_CLIENT .post("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode") .header("Accept", "application/json") .form(&query) .send() }) .await .map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::GetOAuthToken, })?; let status = res.status(); let text = res.text().await.map_err(|source| { MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::GetOAuthToken, } })?; serde_json::from_str(&text).map_err(|source| { MinecraftAuthenticationError::DeserializeResponse { source, raw: text, step: MinecraftAuthStep::GetOAuthToken, status_code: status, } .into() }) } #[tracing::instrument] pub async fn device_login_poll( device_code: &str, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result { let mut query = HashMap::new(); query.insert("client_id", MICROSOFT_CLIENT_ID); query.insert("device_code", device_code); query.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); let res = auth_retry(|| { INSECURE_REQWEST_CLIENT .post(MICROSOFT_TOKEN_URL) .header("Accept", "application/json") .form(&query) .send() }) .await .map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::GetOAuthToken, })?; let status = res.status(); let current_date = get_date_header(res.headers()); let text = res.text().await.map_err(|source| { MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::GetOAuthToken, } })?; if let Ok(error) = serde_json::from_str::(&text) { if error.error == "authorization_pending" || error.error == "slow_down" { return Ok(MinecraftDeviceLoginPoll::Pending { slow_down: error.error == "slow_down", }); } } let token = serde_json::from_str(&text).map_err(|source| { MinecraftAuthenticationError::DeserializeResponse { source, raw: text, step: MinecraftAuthStep::GetOAuthToken, status_code: status, } })?; let credentials = finish_microsoft_login( RequestWithDate { date: current_date, value: token, }, exec, ) .await?; Ok(MinecraftDeviceLoginPoll::Complete { credentials }) } #[tracing::instrument] pub async fn login_finish( code: &str, state: &str, flow: MinecraftLoginFlow, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result { if state != flow.state { return Err(crate::ErrorKind::InputError( "Microsoft sign-in response did not match the active login request" .into(), ) .as_error()); } let oauth_token = oauth_token(code, &flow.verifier, &flow.redirect_uri).await?; finish_microsoft_login(oauth_token, exec).await } async fn finish_microsoft_login( oauth_token: RequestWithDate, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result { let xbl_token = xbox_live_authorize(&oauth_token.value.access_token).await?; let xsts_token = xsts_authorize_xbl(&xbl_token.value.token).await?; let minecraft_token = minecraft_token(xsts_token.value).await?; minecraft_entitlements(&minecraft_token.access_token).await?; let mut credentials = Credentials { offline_profile: MinecraftProfile::default(), account_type: MinecraftAccountType::Microsoft, access_token: minecraft_token.access_token, refresh_token: oauth_token.value.refresh_token, expires: oauth_token.date + Duration::seconds(oauth_token.value.expires_in as i64), active: true, yggdrasil: None, }; let online_profile = credentials .online_profile() .await .ok_or(io::Error::other("Failed to fetch player profile"))?; credentials.offline_profile = MinecraftProfile { id: online_profile.id, name: online_profile.name.clone(), ..credentials.offline_profile }; credentials.upsert(exec).await?; Ok(credentials) } #[derive( Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, )] #[serde(rename_all = "snake_case")] pub enum MinecraftAccountType { #[default] Microsoft, Offline, Yggdrasil, } impl MinecraftAccountType { fn from_database(value: &str) -> Self { match value { "offline" => Self::Offline, "yggdrasil" => Self::Yggdrasil, _ => Self::Microsoft, } } fn as_database(self) -> &'static str { match self { Self::Microsoft => "microsoft", Self::Offline => "offline", Self::Yggdrasil => "yggdrasil", } } } #[derive(sqlx::FromRow)] struct StoredCredentials { uuid: String, active: i64, username: String, account_type: String, access_token: String, refresh_token: String, expires: i64, yggdrasil_api_root: String, yggdrasil_server_name: String, yggdrasil_login: String, yggdrasil_client_token: String, } #[derive(Deserialize, Debug)] pub struct Credentials { /// The offline profile of the user these credentials are for. /// /// Such a profile can only be relied upon to have a proper player UUID, which is /// never changed. A potentially stale username may be available, but no other data /// such as skins or capes is available. #[serde(rename = "profile")] pub offline_profile: MinecraftProfile, #[serde(default)] pub account_type: MinecraftAccountType, pub access_token: String, pub refresh_token: String, pub expires: DateTime, pub active: bool, #[serde(default)] pub yggdrasil: Option, } /// An entry in the player profile cache, keyed by player UUID. pub(super) enum ProfileCacheEntry { /// A cached profile that is valid, even though it may be stale. Hit(Arc), /// A negative profile fetch result due to an authentication error, /// from which we're recovering by holding off from repeatedly /// attempting to fetch the profile until the token is refreshed /// or some time has passed. AuthErrorBackoff { likely_expired_token: String, last_attempt: Instant, }, } /// A thread-safe cache of online profiles, used to avoid fetching the /// same profile multiple times as long as they don't get too stale. /// /// The cache has to be static because credential objects are short lived /// and disposable, and in the future several threads may be interested in /// profile data. pub(super) static PROFILE_CACHE: Mutex< HashMap>, > = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new())); const ONLINE_PROFILE_CACHE_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(60); const ONLINE_PROFILE_LIVE_STATE_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(5); const ONLINE_PROFILE_AUTH_ERROR_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60); #[derive(Debug, Clone, Copy)] enum OnlineProfileCacheIntent { NormalRead, LiveStateRead, RefreshFromMojang, } impl OnlineProfileCacheIntent { fn max_age(self) -> std::time::Duration { match self { Self::NormalRead => ONLINE_PROFILE_CACHE_MAX_AGE, Self::LiveStateRead => ONLINE_PROFILE_LIVE_STATE_MAX_AGE, Self::RefreshFromMojang => std::time::Duration::ZERO, } } fn can_use_stale_on_fetch_error(self) -> bool { matches!(self, Self::LiveStateRead) } } fn validate_offline_username(username: &str) -> crate::Result { let username = username.trim(); if !(1..=16).contains(&username.chars().count()) || !username .chars() .all(|character| character.is_alphanumeric() || character == '_') { return Err(ErrorKind::InputError( "Minecraft usernames must be 1-16 characters and contain only letters, numbers, and underscores" .to_string(), ) .as_error()); } Ok(username.to_string()) } impl Credentials { pub fn offline(username: &str) -> crate::Result { let username = validate_offline_username(username)?; let mut uuid_bytes = md5::compute(format!("OfflinePlayer:{username}")).0; uuid_bytes[6] = (uuid_bytes[6] & 0x0f) | 0x30; uuid_bytes[8] = (uuid_bytes[8] & 0x3f) | 0x80; Self::offline_with_uuid(&username, Uuid::from_bytes(uuid_bytes)) } pub fn offline_with_uuid( username: &str, uuid: Uuid, ) -> crate::Result { let username = validate_offline_username(username)?; Ok(Self { offline_profile: MinecraftProfile { id: uuid, name: username, ..MinecraftProfile::default() }, account_type: MinecraftAccountType::Offline, access_token: "0".to_string(), refresh_token: String::new(), expires: Utc::now(), active: true, yggdrasil: None, }) } pub fn is_offline(&self) -> bool { self.account_type == MinecraftAccountType::Offline } pub fn is_microsoft(&self) -> bool { self.account_type == MinecraftAccountType::Microsoft } pub fn is_yggdrasil(&self) -> bool { self.account_type == MinecraftAccountType::Yggdrasil } fn from_stored(stored: StoredCredentials) -> Self { let account_type = MinecraftAccountType::from_database(&stored.account_type); let yggdrasil = (account_type == MinecraftAccountType::Yggdrasil) .then_some({ YggdrasilAccount { api_root: stored.yggdrasil_api_root, server_name: stored.yggdrasil_server_name, login: stored.yggdrasil_login, client_token: stored.yggdrasil_client_token, } }); Self { offline_profile: MinecraftProfile { id: Uuid::parse_str(&stored.uuid).unwrap_or_default(), name: stored.username, ..MinecraftProfile::default() }, account_type, access_token: stored.access_token, refresh_token: stored.refresh_token, expires: Utc .timestamp_opt(stored.expires, 0) .single() .unwrap_or_else(Utc::now), active: stored.active == 1, yggdrasil, } } /// Refreshes the authentication tokens for this user if they are expired, or /// very close to expiration. async fn refresh( &mut self, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result<()> { if self.is_offline() { return Ok(()); } if self.is_yggdrasil() { return refresh_yggdrasil_credentials(self, exec).await; } // Use a margin of 5 minutes to give e.g. Minecraft and potentially // other operations that depend on a fresh token 5 minutes to complete // from now, and deal with some classes of clock skew if self.expires > Utc::now() + Duration::minutes(5) { return Ok(()); } let oauth_token = oauth_refresh(&self.refresh_token).await?; let xbl_token = xbox_live_authorize(&oauth_token.value.access_token).await?; let xsts_token = xsts_authorize_xbl(&xbl_token.value.token).await?; let minecraft_token = minecraft_token(xsts_token.value).await?; self.access_token = minecraft_token.access_token; self.refresh_token = oauth_token.value.refresh_token; self.expires = oauth_token.date + Duration::seconds(oauth_token.value.expires_in as i64); self.upsert(exec).await?; Ok(()) } /// Returns online profile data when the cached copy is still recent enough. #[tracing::instrument(skip(self))] pub async fn online_profile(&self) -> Option> { self.online_profile_with_cache_intent( OnlineProfileCacheIntent::NormalRead, ) .await } /// Returns profile data recent enough for skin and cape state. /// /// Reuses a profile read from the last few seconds so opening the skins page /// does not send several identical Mojang requests. #[tracing::instrument(skip(self))] pub async fn online_profile_fresh(&self) -> Option> { self.online_profile_with_cache_intent( OnlineProfileCacheIntent::LiveStateRead, ) .await } /// Fetches the online profile from Mojang after a skin or cape change. #[tracing::instrument(skip(self))] pub async fn refresh_online_profile( &self, ) -> Option> { self.online_profile_with_cache_intent( OnlineProfileCacheIntent::RefreshFromMojang, ) .await } async fn online_profile_with_cache_intent( &self, cache_intent: OnlineProfileCacheIntent, ) -> Option> { if self.is_offline() { return None; } if self.is_yggdrasil() { return self.yggdrasil_online_profile(cache_intent).await; } let max_age = cache_intent.max_age(); let stale_profile = { let mut profile_cache = PROFILE_CACHE.lock().await; let mut remove_cached_entry = false; let stale_profile = if let Some(cache_entry) = profile_cache.get(&self.offline_profile.id) { match cache_entry { ProfileCacheEntry::Hit(profile) if profile.is_fresh(max_age) => { return Some(Arc::clone(profile)); } ProfileCacheEntry::Hit(profile) => { Some(Arc::clone(profile)) } // Auth errors must be handled with a backoff strategy because it // has been experimentally found that Mojang quickly rate limits // the profile data endpoint on repeated attempts with bad auth ProfileCacheEntry::AuthErrorBackoff { likely_expired_token, last_attempt, } if &self.access_token != likely_expired_token || Instant::now() .saturating_duration_since(*last_attempt) > ONLINE_PROFILE_AUTH_ERROR_BACKOFF => { remove_cached_entry = true; None } ProfileCacheEntry::AuthErrorBackoff { .. } => { return None; } } } else { None }; if remove_cached_entry { profile_cache.remove(&self.offline_profile.id); } stale_profile }; match minecraft_profile(&self.access_token).await { Ok(profile) => { let profile = Arc::new(profile); let cache_entry = ProfileCacheEntry::Hit(Arc::clone(&profile)); let mut profile_cache = PROFILE_CACHE.lock().await; if self.offline_profile.id != profile.id { profile_cache.remove(&self.offline_profile.id); } profile_cache.insert(profile.id, cache_entry); Some(profile) } Err( err @ MinecraftAuthenticationError::DeserializeResponse { status_code: StatusCode::UNAUTHORIZED, .. }, ) => { tracing::warn!( "Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}", self.offline_profile.id ); let mut profile_cache = PROFILE_CACHE.lock().await; profile_cache.insert( self.offline_profile.id, ProfileCacheEntry::AuthErrorBackoff { likely_expired_token: self.access_token.clone(), last_attempt: Instant::now(), }, ); None } Err(err) => { tracing::warn!( "Failed to fetch online profile for UUID {}: {err}", self.offline_profile.id ); if cache_intent.can_use_stale_on_fetch_error() { stale_profile } else { None } } } } async fn yggdrasil_online_profile( &self, cache_intent: OnlineProfileCacheIntent, ) -> Option> { let account = self.yggdrasil.as_ref()?; let stale_profile = { let profile_cache = PROFILE_CACHE.lock().await; match profile_cache.get(&self.offline_profile.id) { Some(ProfileCacheEntry::Hit(profile)) if profile.is_fresh(cache_intent.max_age()) => { return Some(Arc::clone(profile)); } Some(ProfileCacheEntry::Hit(profile)) => { Some(Arc::clone(profile)) } Some(ProfileCacheEntry::AuthErrorBackoff { .. }) | None => None, } }; match fetch_yggdrasil_profile(account, self.offline_profile.id).await { Ok(Some(profile)) => { let profile = Arc::new(profile); PROFILE_CACHE.lock().await.insert( self.offline_profile.id, ProfileCacheEntry::Hit(Arc::clone(&profile)), ); Some(profile) } Ok(None) => { if cache_intent.can_use_stale_on_fetch_error() { stale_profile } else { None } } Err(error) => { tracing::warn!( "Failed to fetch Yggdrasil profile for UUID {} from {}: {error}", self.offline_profile.id, account.server_name ); if cache_intent.can_use_stale_on_fetch_error() { stale_profile } else { None } } } } /// Attempts to fetch the online profile for this user if possible, and if that fails /// falls back to the known offline profile data. /// /// See also the [`online_profile`](Self::online_profile) method. pub async fn maybe_online_profile( &self, ) -> MaybeOnlineMinecraftProfile<'_> { let online_profile = self.online_profile().await; online_profile.map_or_else( || MaybeOnlineMinecraftProfile::Offline(&self.offline_profile), MaybeOnlineMinecraftProfile::Online, ) } /// Like [`get_active`](Self::get_active), but enforces credentials to be /// successfully refreshed unless the network is unreachable or times out. #[tracing::instrument] pub async fn get_default_credential( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result> { let credentials = Self::get_active_without_refresh(exec).await?; if let Some(mut creds) = credentials { let res = creds.refresh(exec).await; match res { Ok(_) => Ok(Some(creds)), Err(err) => { let network_unavailable = match &*err.raw { ErrorKind::MinecraftAuthenticationError( MinecraftAuthenticationError::Request { source, .. }, ) | ErrorKind::FetchError(source) => { source.is_connect() || source.is_timeout() } _ => false, }; if network_unavailable { return Ok(Some(creds)); } Err(err) } } } else { Ok(None) } } /// Fetches the currently selected credentials from the database, attempting /// to refresh them if they are expired. pub async fn get_active( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result> { Self::get_active_with_refresh(exec, true).await } pub async fn get_active_without_refresh( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result> { Self::get_active_with_refresh(exec, false).await } async fn get_active_with_refresh( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, refresh: bool, ) -> crate::Result> { let res = sqlx::query_as::<_, StoredCredentials>( " SELECT uuid, active, username, account_type, access_token, refresh_token, expires, yggdrasil_api_root, yggdrasil_server_name, yggdrasil_login, yggdrasil_client_token FROM minecraft_users WHERE active = TRUE ", ) .fetch_optional(exec) .await?; Ok(match res { Some(x) => { let mut credentials = Self::from_stored(x); if refresh { credentials.refresh(exec).await.ok(); } Some(credentials) } None => None, }) } pub async fn get_all( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result> { Self::get_all_with_refresh(exec, true).await } pub async fn get_all_without_refresh( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result> { Self::get_all_with_refresh(exec, false).await } async fn get_all_with_refresh( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, refresh: bool, ) -> crate::Result> { let res = sqlx::query_as::<_, StoredCredentials>( " SELECT uuid, active, username, account_type, access_token, refresh_token, expires, yggdrasil_api_root, yggdrasil_server_name, yggdrasil_login, yggdrasil_client_token FROM minecraft_users ", ) .fetch(exec) .try_fold(DashMap::new(), |acc, x| { let mut credentials = Self::from_stored(x); let uuid = credentials.offline_profile.id; async move { if refresh { credentials.refresh(exec).await.ok(); } acc.insert(uuid, credentials); Ok(acc) } }) .await?; Ok(res) } pub async fn get_offline_credential( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result> { let active = Self::get_active_without_refresh(exec).await?; if active.as_ref().is_some_and(Self::is_offline) { return Ok(active); } let users = Self::get_all_without_refresh(exec).await?; Ok(users .into_iter() .map(|(_, credentials)| credentials) .filter(Self::is_offline) .min_by(|left, right| { left.offline_profile.name.cmp(&right.offline_profile.name) })) } pub async fn upsert( &self, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result<()> { let profile = self.maybe_online_profile().await; let expires = self.expires.timestamp(); let uuid = profile.id.as_hyphenated().to_string(); let account_type = self.account_type.as_database(); let yggdrasil_api_root = self .yggdrasil .as_ref() .map_or("", |account| account.api_root.as_str()); let yggdrasil_server_name = self .yggdrasil .as_ref() .map_or("", |account| account.server_name.as_str()); let yggdrasil_login = self .yggdrasil .as_ref() .map_or("", |account| account.login.as_str()); let yggdrasil_client_token = self .yggdrasil .as_ref() .map_or("", |account| account.client_token.as_str()); if self.active { sqlx::query!( " UPDATE minecraft_users SET active = FALSE ", ) .execute(exec) .await?; } sqlx::query( " INSERT INTO minecraft_users ( uuid, active, username, account_type, access_token, refresh_token, expires, yggdrasil_api_root, yggdrasil_server_name, yggdrasil_login, yggdrasil_client_token ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (uuid) DO UPDATE SET active = $2, username = $3, account_type = $4, access_token = $5, refresh_token = $6, expires = $7, yggdrasil_api_root = $8, yggdrasil_server_name = $9, yggdrasil_login = $10, yggdrasil_client_token = $11 ", ) .bind(uuid) .bind(self.active) .bind(&profile.name) .bind(account_type) .bind(&self.access_token) .bind(&self.refresh_token) .bind(expires) .bind(yggdrasil_api_root) .bind(yggdrasil_server_name) .bind(yggdrasil_login) .bind(yggdrasil_client_token) .execute(exec) .await?; Ok(()) } pub async fn remove( uuid: Uuid, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, ) -> crate::Result<()> { let uuid = uuid.as_hyphenated().to_string(); sqlx::query!( " DELETE FROM minecraft_users WHERE uuid = $1 ", uuid, ) .execute(exec) .await?; Ok(()) } } impl Serialize for Credentials { fn serialize( &self, serializer: S, ) -> Result { // Opportunistically hydrate the profile with its online data if possible for frontend // consumption, transparently handling all the possible Tokio runtime states the current // thread may be in the most efficient way let profile = if self.is_offline() { MaybeOnlineMinecraftProfile::Offline(&self.offline_profile) } else { match Handle::try_current().ok() { Some(runtime) if runtime.runtime_flavor() == RuntimeFlavor::CurrentThread => { runtime.block_on(self.maybe_online_profile()) } Some(runtime) => task::block_in_place(|| { runtime.block_on(self.maybe_online_profile()) }), None => tokio::runtime::Builder::new_current_thread() .enable_all() .build() .map_or_else( |_| { MaybeOnlineMinecraftProfile::Offline( &self.offline_profile, ) }, |runtime| runtime.block_on(self.maybe_online_profile()), ), } }; let mut ser = serializer.serialize_struct("Credentials", 7)?; ser.serialize_field("profile", &*profile)?; ser.serialize_field("account_type", &self.account_type)?; ser.serialize_field("access_token", &self.access_token)?; ser.serialize_field("refresh_token", &self.refresh_token)?; ser.serialize_field("expires", &self.expires)?; ser.serialize_field("active", &self.active)?; ser.serialize_field("yggdrasil", &self.yggdrasil)?; ser.end() } } #[cfg(test)] mod offline_account_tests { use super::*; #[test] fn creates_java_compatible_offline_uuid() { let credentials = Credentials::offline("Notch").unwrap(); assert_eq!( credentials.offline_profile.id, Uuid::parse_str("b50ad385-829d-3141-a216-7e7d7539ba7f").unwrap() ); assert_eq!(credentials.account_type, MinecraftAccountType::Offline); assert_eq!(credentials.access_token, "0"); } #[test] fn creates_offline_account_with_custom_uuid() { let uuid = Uuid::new_v4(); let credentials = Credentials::offline_with_uuid("CustomUuid", uuid).unwrap(); assert_eq!(credentials.offline_profile.id, uuid); assert_eq!(credentials.offline_profile.name, "CustomUuid"); assert_eq!(credentials.account_type, MinecraftAccountType::Offline); assert_eq!(credentials.access_token, "0"); } #[test] fn validates_offline_username() { assert!(Credentials::offline("abc").is_ok()); assert!(Credentials::offline("Player_123").is_ok()); assert!(Credentials::offline("玩家").is_ok()); assert!(Credentials::offline("玩家_123").is_ok()); assert!(Credentials::offline("").is_err()); assert!(Credentials::offline("player name").is_err()); assert!(Credentials::offline("玩家!").is_err()); assert!( Credentials::offline( "这是一个超过十六个字符长度的中文离线玩家名称" ) .is_err() ); } #[tokio::test] async fn persists_offline_account_as_active() { let pool = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::migrate!().run(&pool).await.unwrap(); let credentials = Credentials::offline("OfflineUser").unwrap(); credentials.upsert(&pool).await.unwrap(); let stored = Credentials::get_active(&pool).await.unwrap().unwrap(); assert_eq!(stored.offline_profile.name, "OfflineUser"); assert_eq!(stored.offline_profile.id, credentials.offline_profile.id); assert!(stored.is_offline()); assert!(stored.active); } #[tokio::test] async fn persists_offline_account_with_custom_uuid() { let pool = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::migrate!().run(&pool).await.unwrap(); let uuid = Uuid::new_v4(); let credentials = Credentials::offline_with_uuid("CustomUuid", uuid).unwrap(); credentials.upsert(&pool).await.unwrap(); let stored = Credentials::get_active(&pool).await.unwrap().unwrap(); assert_eq!(stored.offline_profile.id, uuid); assert_eq!(stored.offline_profile.name, "CustomUuid"); assert!(stored.is_offline()); } #[tokio::test] async fn selects_offline_account_when_online_account_is_active() { let pool = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::migrate!().run(&pool).await.unwrap(); let mut offline = Credentials::offline("OfflineUser").unwrap(); offline.active = false; offline.upsert(&pool).await.unwrap(); sqlx::query( " INSERT INTO minecraft_users ( uuid, active, username, account_type, access_token, refresh_token, expires ) VALUES ($1, TRUE, $2, 'microsoft', $3, $4, $5) ", ) .bind(Uuid::new_v4().as_hyphenated().to_string()) .bind("OnlineUser") .bind("expired-access-token") .bind("expired-refresh-token") .bind(0_i64) .execute(&pool) .await .unwrap(); let selected = Credentials::get_offline_credential(&pool) .await .unwrap() .unwrap(); assert!(selected.is_offline()); assert_eq!(selected.offline_profile.id, offline.offline_profile.id); } } pub struct DeviceTokenPair { pub token: DeviceToken, pub key: DeviceTokenKey, } impl DeviceTokenPair { #[tracing::instrument(skip(exec))] async fn refresh_and_get_device_token( current_date: DateTime, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result<(Self, DateTime)> { let pair = Self::get(exec).await?; if let Some(mut pair) = pair { if pair.token.not_after > current_date { Ok((pair, current_date)) } else { let res = device_token(&pair.key, current_date).await?; pair.token = res.value; pair.upsert(exec).await?; Ok((pair, res.date)) } } else { let key = generate_key()?; let res = device_token(&key, current_date).await?; let pair = Self { key, token: res.value, }; pair.upsert(exec).await?; Ok((pair, res.date)) } } async fn get( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, ) -> crate::Result> { let res = sqlx::query!( r#" SELECT uuid, private_key, x, y, issue_instant, not_after, token, json(display_claims) as "display_claims!: serde_json::Value" FROM minecraft_device_tokens "# ) .fetch_optional(exec) .await?; if let Some(x) = res && let Ok(uuid) = Uuid::parse_str(&x.uuid) && let Ok(private_key) = SigningKey::from_pkcs8_pem(&x.private_key) { return Ok(Some(Self { token: DeviceToken { issue_instant: Utc .timestamp_opt(x.issue_instant, 0) .single() .unwrap_or_else(Utc::now), not_after: Utc .timestamp_opt(x.not_after, 0) .single() .unwrap_or_else(Utc::now), token: x.token, display_claims: serde_json::from_value(x.display_claims) .unwrap_or_default(), }, key: DeviceTokenKey { id: uuid, key: private_key, x: x.x, y: x.y, }, })); } Ok(None) } pub async fn upsert( &self, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, ) -> crate::Result<()> { let uuid = self.key.id.as_hyphenated().to_string(); let issue_instant = self.token.issue_instant.timestamp(); let not_after = self.token.not_after.timestamp(); let key = self .key .key .to_pkcs8_pem(LineEnding::default()) .map_err(MinecraftAuthenticationError::PEMSerialize)? .to_string(); let display_claims = serde_json::to_string(&self.token.display_claims)?; sqlx::query!( " INSERT INTO minecraft_device_tokens (id, uuid, private_key, x, y, issue_instant, not_after, token, display_claims) VALUES (0, $1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO UPDATE SET uuid = $1, private_key = $2, x = $3, y = $4, issue_instant = $5, not_after = $6, token = $7, display_claims = jsonb($8) ", uuid, key, self.key.x, self.key.y, issue_instant, not_after, self.token.token, display_claims, ) .execute(exec) .await?; Ok(()) } } const MICROSOFT_CLIENT_ID: &str = "c7104738-eff0-4fc9-a4ad-59e2c72ec691"; const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf"; const BROWSER_AUTH_REPLY_URL: &str = "http://127.0.0.1:53682/oauth/microsoft/callback"; const MICROSOFT_AUTHORIZE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"; const MICROSOFT_TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; const REQUESTED_SCOPE: &str = "XboxLive.signin offline_access"; pub const MINECRAFT_SERVICES_USER_AGENT: &str = "Axolotl Launcher"; pub struct RequestWithDate { pub date: DateTime, pub value: T, } // flow steps #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "PascalCase")] pub struct DeviceToken { pub issue_instant: DateTime, pub not_after: DateTime, pub token: String, pub display_claims: HashMap, } #[tracing::instrument(skip(key))] pub async fn device_token( key: &DeviceTokenKey, current_date: DateTime, ) -> Result, MinecraftAuthenticationError> { let res = send_signed_request( None, "https://device.auth.xboxlive.com/device/authenticate", "/device/authenticate", json!({ "Properties": { "AuthMethod": "ProofOfPossession", "Id": format!("{{{}}}", key.id.to_string().to_uppercase()), "DeviceType": "Win32", "Version": "10.16.0", "ProofKey": { "kty": "EC", "x": key.x, "y": key.y, "crv": "P-256", "alg": "ES256", "use": "sig" } }, "RelyingParty": "http://auth.xboxlive.com", "TokenType": "JWT" }), key, MinecraftAuthStep::GetDeviceToken, current_date, ) .await?; Ok(RequestWithDate { date: res.current_date, value: res.body, }) } #[derive(Deserialize)] #[serde(rename_all = "PascalCase")] struct RedirectUri { pub msa_oauth_redirect: String, } #[tracing::instrument(skip(key))] async fn sisu_authenticate( token: &str, challenge: &str, key: &DeviceTokenKey, current_date: DateTime, ) -> Result<(String, RequestWithDate), MinecraftAuthenticationError> { let res = send_signed_request::( None, "https://sisu.xboxlive.com/authenticate", "/authenticate", json!({ "AppId": MICROSOFT_CLIENT_ID, "DeviceToken": token, "Offers": [ REQUESTED_SCOPE ], "Query": { "code_challenge": challenge, "code_challenge_method": "S256", "state": generate_oauth_challenge(), "prompt": "select_account" }, "RedirectUri": AUTH_REPLY_URL, "Sandbox": "RETAIL", "TokenType": "code", "TitleId": "1794566092", }), key, MinecraftAuthStep::SisuAuthenticate, current_date, ) .await?; let session_id = res .headers .get("X-SessionId") .and_then(|x| x.to_str().ok()) .ok_or_else(|| MinecraftAuthenticationError::NoSessionId)? .to_string(); Ok(( session_id, RequestWithDate { date: res.current_date, value: res.body, }, )) } #[derive(Deserialize)] struct OAuthToken { // pub token_type: String, pub expires_in: u64, // pub scope: String, pub access_token: String, pub refresh_token: String, // pub user_id: String, // pub foci: String, } #[derive(Deserialize)] struct MicrosoftOAuthError { error: String, } #[tracing::instrument] async fn oauth_token( code: &str, verifier: &str, redirect_uri: &str, ) -> Result, MinecraftAuthenticationError> { let mut query = HashMap::new(); query.insert("client_id", MICROSOFT_CLIENT_ID); query.insert("code", code); query.insert("code_verifier", verifier); query.insert("grant_type", "authorization_code"); query.insert("redirect_uri", redirect_uri); query.insert("scope", REQUESTED_SCOPE); let res = auth_retry(|| { INSECURE_REQWEST_CLIENT .post(MICROSOFT_TOKEN_URL) .header("Accept", "application/json") .form(&query) .send() }) .await .map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::GetOAuthToken, })?; let status = res.status(); let current_date = get_date_header(res.headers()); let text = res.text().await.map_err(|source| { MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::GetOAuthToken, } })?; let body = serde_json::from_str(&text).map_err(|source| { MinecraftAuthenticationError::DeserializeResponse { source, raw: text, step: MinecraftAuthStep::GetOAuthToken, status_code: status, } })?; Ok(RequestWithDate { date: current_date, value: body, }) } #[tracing::instrument] async fn oauth_refresh( refresh_token: &str, ) -> Result, MinecraftAuthenticationError> { let mut query = HashMap::new(); query.insert("client_id", MICROSOFT_CLIENT_ID); query.insert("refresh_token", refresh_token); query.insert("grant_type", "refresh_token"); query.insert("scope", REQUESTED_SCOPE); let res = auth_retry(|| { INSECURE_REQWEST_CLIENT .post(MICROSOFT_TOKEN_URL) .header("Accept", "application/json") .form(&query) .send() }) .await .map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::RefreshOAuthToken, })?; let status = res.status(); let current_date = get_date_header(res.headers()); let text = res.text().await.map_err(|source| { MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::RefreshOAuthToken, } })?; let body = serde_json::from_str(&text).map_err(|source| { MinecraftAuthenticationError::DeserializeResponse { source, raw: text, step: MinecraftAuthStep::RefreshOAuthToken, status_code: status, } })?; Ok(RequestWithDate { date: current_date, value: body, }) } #[derive(Deserialize)] #[serde(rename_all = "PascalCase")] struct XboxLiveToken { token: String, } #[tracing::instrument(skip(access_token))] async fn xbox_live_authorize( access_token: &str, ) -> Result, MinecraftAuthenticationError> { send_xbox_json_request( "https://user.auth.xboxlive.com/user/authenticate", json!({ "Properties": { "AuthMethod": "RPS", "SiteName": "user.auth.xboxlive.com", "RpsTicket": format!("d={access_token}"), }, "RelyingParty": "http://auth.xboxlive.com", "TokenType": "JWT", }), MinecraftAuthStep::SisuAuthorize, ) .await } #[tracing::instrument(skip(xbl_token))] async fn xsts_authorize_xbl( xbl_token: &str, ) -> Result, MinecraftAuthenticationError> { send_xbox_json_request( "https://xsts.auth.xboxlive.com/xsts/authorize", json!({ "RelyingParty": "rp://api.minecraftservices.com/", "TokenType": "JWT", "Properties": { "SandboxId": "RETAIL", "UserTokens": [xbl_token], }, }), MinecraftAuthStep::XstsAuthorize, ) .await } async fn send_xbox_json_request( url: &str, body: serde_json::Value, step: MinecraftAuthStep, ) -> Result, MinecraftAuthenticationError> { let res = auth_retry(|| { INSECURE_REQWEST_CLIENT .post(url) .header("Content-Type", "application/json") .header("Accept", "application/json") .json(&body) .send() }) .await .map_err(|source| MinecraftAuthenticationError::Request { source, step })?; let status = res.status(); let date = get_date_header(res.headers()); let text = res.text().await.map_err(|source| { MinecraftAuthenticationError::Request { source, step } })?; let value = serde_json::from_str(&text).map_err(|source| { MinecraftAuthenticationError::DeserializeResponse { source, raw: text, step, status_code: status, } })?; Ok(RequestWithDate { date, value }) } #[derive(Deserialize)] struct MinecraftToken { // pub username: String, pub access_token: String, // pub token_type: String, // pub expires_in: u64, } #[tracing::instrument] async fn minecraft_token( token: DeviceToken, ) -> Result { let uhs = token .display_claims .get("xui") .and_then(|x| x.get(0)) .and_then(|x| x.get("uhs")) .and_then(|x| x.as_str().map(String::from)) .ok_or_else(|| MinecraftAuthenticationError::NoUserHash)?; let token = token.token; let url = mojang_service_url( "https://api.minecraftservices.com/launcher/login", should_use_mojang_mirror(), ); let res = auth_retry(|| { INSECURE_REQWEST_CLIENT .post(url.as_ref()) .header("Accept", "application/json") .header("User-Agent", MINECRAFT_SERVICES_USER_AGENT) .json(&json!({ "platform": "PC_LAUNCHER", "xtoken": format!("XBL3.0 x={uhs};{token}"), })) .send() }) .await .map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftToken, })?; let status = res.status(); let text = res.text().await.map_err(|source| { MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftToken, } })?; serde_json::from_str(&text).map_err(|source| { MinecraftAuthenticationError::DeserializeResponse { source, raw: text, step: MinecraftAuthStep::MinecraftToken, status_code: status, } }) } #[derive( sqlx::Type, Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq, )] #[serde(rename_all = "UPPERCASE")] #[sqlx(rename_all = "UPPERCASE")] pub enum MinecraftSkinVariant { /// The classic player model, with arms that are 4 pixels wide. Classic, /// The slim player model, with arms that are 3 pixels wide. Slim, /// The player model is unknown. #[serde(other)] Unknown, // Defensive handling of unexpected Mojang API return values to // prevent breaking the entire profile parsing } #[derive(Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq)] #[serde(rename_all = "UPPERCASE")] pub enum MinecraftCharacterExpressionState { /// This expression is selected for being displayed ingame. /// /// At the moment, at most one expression can be selected at a time. Active, /// This expression is not selected for being displayed ingame. Inactive, /// The expression selection status is unknown. #[serde(other)] Unknown, // Defensive handling of unexpected Mojang API return values to // prevent breaking the entire profile parsing } #[derive(Deserialize, Serialize, Debug, Clone)] pub struct MinecraftSkin { /// The UUID of this skin object. /// /// As of 2025-04-08, in the production Mojang profile endpoint this UUID /// changes every time the player changes their skin, even if the skin /// texture is the same as before. pub id: Uuid, /// The selection state of the skin. /// /// As of 2025-04-08, in the production Mojang profile endpoint this /// is always `ACTIVE`, as only a single skin representing the current /// skin is returned. pub state: MinecraftCharacterExpressionState, /// The URL to the skin texture. /// /// As of 2025-04-08, in the production Mojang profile endpoint the file /// name for this URL is a hash of the skin texture, so that different /// players using the same skin texture will share a texture URL. pub url: Arc, /// A hash of the skin texture. /// /// As of 2025-04-08, in the production Mojang profile endpoint this /// is always set and the same as the file name of the skin texture URL. #[serde( default, // Defensive handling of unexpected Mojang API return values to // prevent breaking the entire profile parsing rename = "textureKey" )] pub texture_key: Option>, /// The player model variant this skin is for. pub variant: MinecraftSkinVariant, /// User-friendly name for the skin. /// /// As of 2025-04-08, in the production Mojang profile endpoint this is /// only set if the player has not set a custom skin, and this skin object /// is therefore the default skin for the player's UUID. #[serde( default, rename = "alias", deserialize_with = "normalize_skin_alias_case" )] pub name: Option, } impl MinecraftSkin { /// Robustly computes the texture key for this skin, falling back to its /// URL file name and finally to the skin UUID when necessary. pub fn texture_key(&self) -> Arc { self.texture_key.as_ref().cloned().unwrap_or_else(|| { self.url .path_segments() .and_then(|mut path_segments| { path_segments.next_back().map(String::from) }) .unwrap_or_else(|| self.id.as_simple().to_string()) .into() }) } } fn normalize_skin_alias_case<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { // Skin aliases have been spotted to be returned in all caps, so make sure // they are normalized to a prettier title case Ok(>>::deserialize(deserializer)? .map(|alias| alias.to_title_case())) } #[derive(Deserialize, Serialize, Debug, Clone)] pub struct MinecraftCape { /// The UUID of the cape. pub id: Uuid, /// The selection state of the cape. pub state: MinecraftCharacterExpressionState, /// The URL to the cape texture. pub url: Arc, /// The user-friendly name for the cape. #[serde(rename = "alias")] pub name: Arc, } #[derive(Deserialize, Serialize, Debug, Default, Clone)] pub struct MinecraftProfile { /// The UUID of the player. #[serde(default)] pub id: Uuid, /// The username of the player. pub name: String, /// The skins the player is known to have. /// /// As of 2025-04-08, in the production Mojang profile endpoint every /// player has a single skin. pub skins: Vec, /// The capes the player is known to have. pub capes: Vec, /// The instant when the profile was fetched. See also [Self::is_fresh]. #[serde(skip)] pub fetch_time: Option, } impl MinecraftProfile { /// Checks whether the profile data is fresh (i.e., highly likely to be /// up-to-date because it was fetched recently) or stale. If it is not /// known when this profile data has been fetched from Mojang servers (i.e., /// `fetch_time` is `None`), the profile is considered stale. /// /// This can be used to determine if the profile data should be fetched again /// from the Mojang API: the vanilla launcher was seen refreshing profile /// data every 60 seconds when re-entering the skin selection screen, and /// external applications may change this data at any time. fn is_fresh(&self, max_age: std::time::Duration) -> bool { self.fetch_time.is_some_and(|last_profile_fetch_time| { Instant::now().saturating_duration_since(last_profile_fetch_time) < max_age }) } /// Returns the currently selected skin for this profile. pub fn current_skin(&self) -> crate::Result<&MinecraftSkin> { Ok(self .skins .iter() .find(|skin| { skin.state == MinecraftCharacterExpressionState::Active }) // There should always be one active skin, even when the player uses their default skin .ok_or_else(|| { ErrorKind::OtherError("No active skin found".into()) })?) } /// Returns the currently selected cape for this profile. pub fn current_cape(&self) -> Option<&MinecraftCape> { self.capes.iter().find(|cape| { cape.state == MinecraftCharacterExpressionState::Active }) } } pub enum MaybeOnlineMinecraftProfile<'profile> { /// An online profile, fetched from the Mojang API. Online(Arc), /// An offline profile, which has not been fetched from the Mojang API. Offline(&'profile MinecraftProfile), } impl Deref for MaybeOnlineMinecraftProfile<'_> { type Target = MinecraftProfile; fn deref(&self) -> &Self::Target { match self { Self::Online(profile) => profile, Self::Offline(profile) => profile, } } } #[tracing::instrument(skip(token))] async fn minecraft_profile( token: &str, ) -> Result { let url = mojang_service_url( "https://api.minecraftservices.com/minecraft/profile", should_use_mojang_mirror(), ); let res = auth_retry(|| { INSECURE_REQWEST_CLIENT .get(url.as_ref()) .header("Accept", "application/json") .header("User-Agent", MINECRAFT_SERVICES_USER_AGENT) .bearer_auth(token) // Profiles may be refreshed periodically in response to user actions, // so we want each refresh to be fast .timeout(std::time::Duration::from_secs(10)) .send() }) .await .map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftProfile, })?; let status = res.status(); let text = res.text().await.map_err(|source| { MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftProfile, } })?; let mut profile = serde_json::from_str::(&text).map_err(|source| { MinecraftAuthenticationError::DeserializeResponse { source, raw: text, step: MinecraftAuthStep::MinecraftProfile, status_code: status, } })?; profile.fetch_time = Some(Instant::now()); tracing::debug!( "Successfully fetched Minecraft profile for {}", profile.name ); Ok(profile) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MinecraftEntitlements { items: Vec, } #[derive(Deserialize)] struct MinecraftEntitlement { name: String, } #[tracing::instrument] async fn minecraft_entitlements( token: &str, ) -> Result { let request_url = format!( "https://api.minecraftservices.com/entitlements/license?requestId={}", Uuid::new_v4() ); let url = mojang_service_url(&request_url, should_use_mojang_mirror()); let res = auth_retry(|| { INSECURE_REQWEST_CLIENT .get(url.as_ref()) .header("Accept", "application/json") .header("User-Agent", MINECRAFT_SERVICES_USER_AGENT) .bearer_auth(token) .send() }) .await .map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements, })?; let status = res.status(); let text = res.text().await.map_err(|source| { MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements, } })?; let entitlements = serde_json::from_str::(&text) .map_err(|source| { MinecraftAuthenticationError::DeserializeResponse { source, raw: text, step: MinecraftAuthStep::MinecraftEntitlements, status_code: status, } })?; if entitlements .items .iter() .any(|item| item.name == "game_minecraft") { Ok(entitlements) } else { Err(MinecraftAuthenticationError::NoMinecraftEntitlement) } } // auth utils #[tracing::instrument(skip(reqwest_request))] async fn auth_retry( reqwest_request: impl Fn() -> F, ) -> Result where F: Future>, { const RETRY_COUNT: usize = 5; // Does command 9 times const RETRY_WAIT: std::time::Duration = std::time::Duration::from_millis(250); let mut resp = reqwest_request().await; for i in 0..RETRY_COUNT { match &resp { Ok(_) => { break; } Err(err) => { if err.is_connect() || err.is_timeout() { if i < RETRY_COUNT - 1 { tracing::debug!( "Request failed with connect error, retrying...", ); tokio::time::sleep(RETRY_WAIT).await; resp = reqwest_request().await; } else { break; } } } } } resp } pub struct DeviceTokenKey { pub id: Uuid, pub key: SigningKey, pub x: String, pub y: String, } #[tracing::instrument] fn generate_key() -> Result { let uuid = Uuid::new_v4(); let signing_key = SigningKey::random(&mut OsRng); let public_key = VerifyingKey::from(&signing_key); let encoded_point = public_key.to_encoded_point(false); Ok(DeviceTokenKey { id: uuid, key: signing_key, x: BASE64_URL_SAFE_NO_PAD.encode( encoded_point.x().ok_or_else(|| { MinecraftAuthenticationError::ReadingPublicKey })?, ), y: BASE64_URL_SAFE_NO_PAD.encode( encoded_point.y().ok_or_else(|| { MinecraftAuthenticationError::ReadingPublicKey })?, ), }) } struct SignedRequestResponse { pub headers: HeaderMap, pub current_date: DateTime, pub body: T, } #[tracing::instrument(skip(key))] async fn send_signed_request( authorization: Option<&str>, url: &str, url_path: &str, raw_body: serde_json::Value, key: &DeviceTokenKey, step: MinecraftAuthStep, current_date: DateTime, ) -> Result, MinecraftAuthenticationError> { let auth = authorization.map_or(Vec::new(), |v| v.as_bytes().to_vec()); let body = serde_json::to_vec(&raw_body).map_err(|source| { MinecraftAuthenticationError::SerializeBody { source, step } })?; let time: u128 = { ((current_date.timestamp() as u128) + 11644473600) * 10000000 }; let mut buffer = Vec::new(); buffer.extend_from_slice(&1_u32.to_be_bytes()[..]); buffer.push(0_u8); buffer.extend_from_slice(&(time as u64).to_be_bytes()[..]); buffer.push(0_u8); buffer.extend_from_slice("POST".as_bytes()); buffer.push(0_u8); buffer.extend_from_slice(url_path.as_bytes()); buffer.push(0_u8); buffer.extend_from_slice(&auth); buffer.push(0_u8); buffer.extend_from_slice(&body); buffer.push(0_u8); let ecdsa_sig: Signature = key.key.sign(&buffer); let mut sig_buffer = Vec::new(); sig_buffer.extend_from_slice(&1_i32.to_be_bytes()[..]); sig_buffer.extend_from_slice(&(time as u64).to_be_bytes()[..]); sig_buffer.extend_from_slice(&ecdsa_sig.r().to_bytes()); sig_buffer.extend_from_slice(&ecdsa_sig.s().to_bytes()); let signature = BASE64_STANDARD.encode(&sig_buffer); let res = auth_retry(|| { let mut request = INSECURE_REQWEST_CLIENT .post(url) .header("Content-Type", "application/json; charset=utf-8") .header("Accept", "application/json") .header("Signature", &signature); if url != "https://sisu.xboxlive.com/authorize" { request = request.header("x-xbl-contract-version", "1"); } if let Some(auth) = authorization { request = request.header("Authorization", auth); } request.body(body.clone()).send() }) .await .map_err(|source| MinecraftAuthenticationError::Request { source, step })?; let status = res.status(); let headers = res.headers().clone(); let current_date = get_date_header(&headers); let body = res.text().await.map_err(|source| { MinecraftAuthenticationError::Request { source, step } })?; let body = serde_json::from_str(&body).map_err(|source| { MinecraftAuthenticationError::DeserializeResponse { source, raw: body, step, status_code: status, } })?; Ok(SignedRequestResponse { headers, current_date, body, }) } #[tracing::instrument] fn get_date_header(headers: &HeaderMap) -> DateTime { headers .get(reqwest::header::DATE) .and_then(|x| x.to_str().ok()) .and_then(|x| DateTime::parse_from_rfc2822(x).ok()) .map_or(Utc::now(), |x| x.with_timezone(&Utc)) } #[tracing::instrument] fn generate_oauth_challenge() -> String { let mut rng = rand::thread_rng(); let bytes: Vec = (0..64).map(|_| rng.r#gen::()).collect(); bytes.iter().map(|byte| format!("{byte:02x}")).collect() }