Compare commits

...

5 Commits

Author SHA1 Message Date
Alejandro González
f07bc86711
chore: remove dead profile_run_credentials plugin command 2025-06-14 16:17:44 +02:00
Alejandro González
23f0c1dbf7
chore: document differences between similar Credentials methods 2025-06-14 16:16:24 +02:00
Alejandro González
8abbc021ea
chore: fix new prettier lints 2025-06-14 16:16:24 +02:00
Alejandro González
ed8ff79809
refactor(theseus): extend auth subsystem to fetch complete user profiles 2025-06-14 16:16:22 +02:00
Alejandro González
f210de563d
chore: typo fix and formatting tidyups 2025-06-14 16:14:29 +02:00
15 changed files with 509 additions and 138 deletions

1
Cargo.lock generated
View File

@ -8889,6 +8889,7 @@ dependencies = [
"flate2",
"fs4",
"futures",
"heck 0.5.0",
"hickory-resolver",
"indicatif",
"notify",

View File

@ -60,6 +60,7 @@ flate2 = "1.1.2"
fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31"
heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"

View File

@ -10,12 +10,12 @@
size="36px"
:src="
selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
? `https://mc-heads.net/avatar/${selectedAccount.profile.id}/128`
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
<div class="flex flex-col w-full">
<span>{{ selectedAccount ? selectedAccount.username : 'Select account' }}</span>
<span>{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}</span>
<span class="text-secondary text-xs">Minecraft account</span>
</div>
<DropdownIcon class="w-5 h-5 shrink-0" />
@ -28,12 +28,17 @@
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
>
<div v-if="selectedAccount" class="selected account">
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.profile.id}/128`" />
<div>
<h4>{{ selectedAccount.username }}</h4>
<h4>{{ selectedAccount.profile.name }}</h4>
<p>Selected</p>
</div>
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
<Button
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<TrashIcon />
</Button>
</div>
@ -44,12 +49,12 @@
</Button>
</div>
<div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
<Button class="option account" @click="setAccount(account)">
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
<p>{{ account.username }}</p>
<Avatar :src="`https://mc-heads.net/avatar/${account.profile.id}/128`" class="icon" />
<p>{{ account.profile.name }}</p>
</Button>
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
<TrashIcon />
</Button>
</div>
@ -101,16 +106,16 @@ defineExpose({
await refreshValues()
const displayAccounts = computed(() =>
accounts.value.filter((account) => defaultUser.value !== account.id),
accounts.value.filter((account) => defaultUser.value !== account.profile.id),
)
const selectedAccount = computed(() =>
accounts.value.find((account) => account.id === defaultUser.value),
accounts.value.find((account) => account.profile.id === defaultUser.value),
)
async function setAccount(account) {
defaultUser.value = account.id
await set_default_user(account.id).catch(handleError)
defaultUser.value = account.profile.id
await set_default_user(account.profile.id).catch(handleError)
emit('change')
}

View File

@ -92,7 +92,7 @@ async function loginMinecraft() {
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.id).catch(handleError)
await set_default_user(loggedIn.profile.id).catch(handleError)
}
await trackEvent('AccountLogIn', { source: 'ErrorModal' })

View File

@ -27,7 +27,10 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
let credentials = minecraft_auth::finish_login(&input, login).await?;
println!("Logged in user {}.", credentials.username);
println!(
"Logged in user {}.",
credentials.maybe_online_profile().await.name
);
Ok(credentials)
}

View File

@ -151,7 +151,6 @@ fn main() {
"profile_update_managed_modrinth_version",
"profile_repair_managed_modrinth",
"profile_run",
"profile_run_credentials",
"profile_kill",
"profile_edit",
"profile_edit_icon",

View File

@ -28,7 +28,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_update_managed_modrinth_version,
profile_repair_managed_modrinth,
profile_run,
profile_run_credentials,
profile_kill,
profile_edit,
profile_edit_icon,
@ -256,22 +255,6 @@ pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
Ok(process)
}
// Run Minecraft using a profile using chosen credentials
// Returns the UUID, which can be used to poll
// for the actual Child in the state.
// invoke('plugin:profile|profile_run_credentials', {path, credentials})')
#[tauri::command]
pub async fn profile_run_credentials(
path: &str,
credentials: Credentials,
) -> Result<ProcessMetadata> {
let process =
profile::run_credentials(path, &credentials, &QuickPlayType::None)
.await?;
Ok(process)
}
#[tauri::command]
pub async fn profile_kill(path: &str) -> Result<()> {
profile::kill(path).await?;

View File

@ -38,6 +38,7 @@ tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] }
tracing-error.workspace = true
paste.workspace = true
heck.workspace = true
tauri = { workspace = true, optional = true }
indicatif = { workspace = true, optional = true }

View File

@ -39,21 +39,27 @@ pub struct LatestLogCursor {
#[serde(transparent)]
pub struct CensoredString(String);
impl CensoredString {
pub fn censor(mut s: String, credentials_set: &Vec<Credentials>) -> Self {
pub fn censor(mut s: String, credentials_list: &[Credentials]) -> Self {
let username = whoami::username();
s = s
.replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/")
.replace(&format!("\\{username}\\"), "\\{COMPUTER_USERNAME}\\");
for credentials in credentials_set {
for credentials in credentials_list {
// Use the offline profile to guarantee that this function is does not cause
// Mojang API request, and is never delayed by a network request. The offline
// profile is optimistically updated on upsert from time to time anyway
s = s
.replace(&credentials.access_token, "{MINECRAFT_ACCESS_TOKEN}")
.replace(&credentials.username, "{MINECRAFT_USERNAME}")
.replace(
&credentials.id.as_simple().to_string(),
&credentials.offline_profile.name,
"{MINECRAFT_USERNAME}",
)
.replace(
&credentials.offline_profile.id.as_simple().to_string(),
"{MINECRAFT_UUID}",
)
.replace(
&credentials.id.as_hyphenated().to_string(),
&credentials.offline_profile.id.as_hyphenated().to_string(),
"{MINECRAFT_UUID}",
);
}
@ -210,7 +216,7 @@ pub async fn get_output_by_filename(
.await?
.into_iter()
.map(|x| x.1)
.collect();
.collect::<Vec<_>>();
// Load .gz file into String
if let Some(ext) = path.extension() {
@ -350,7 +356,7 @@ pub async fn get_generic_live_log_cursor(
.await?
.into_iter()
.map(|x| x.1)
.collect();
.collect::<Vec<_>>();
let output = CensoredString::censor(output, &credentials);
Ok(LatestLogCursor {
cursor,

View File

@ -23,8 +23,8 @@ pub async fn finish_login(
#[tracing::instrument]
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
let state = State::get().await?;
let users = Credentials::get_active(&state.pool).await?;
Ok(users.map(|x| x.id))
let user = Credentials::get_active(&state.pool).await?;
Ok(user.map(|user| user.offline_profile.id))
}
#[tracing::instrument]

View File

@ -642,9 +642,8 @@ pub async fn run(
}
/// Run Minecraft using a profile, and credentials for authentication
/// Returns Arc pointer to RwLock to Child
#[tracing::instrument(skip(credentials))]
pub async fn run_credentials(
async fn run_credentials(
path: &str,
credentials: &Credentials,
quick_play_type: &QuickPlayType,

View File

@ -211,7 +211,7 @@ fn parse_jvm_argument(
}
#[allow(clippy::too_many_arguments)]
pub fn get_minecraft_arguments(
pub async fn get_minecraft_arguments(
arguments: Option<&[Argument]>,
legacy_arguments: Option<&str>,
credentials: &Credentials,
@ -224,6 +224,9 @@ pub fn get_minecraft_arguments(
java_arch: &str,
quick_play_type: &QuickPlayType,
) -> crate::Result<Vec<String>> {
let access_token = credentials.access_token.clone();
let profile = credentials.maybe_online_profile().await;
if let Some(arguments) = arguments {
let mut parsed_arguments = Vec::new();
@ -233,9 +236,9 @@ pub fn get_minecraft_arguments(
|arg| {
parse_minecraft_argument(
arg,
&credentials.access_token,
&credentials.username,
credentials.id,
&access_token,
&profile.name,
profile.id,
version,
asset_index_name,
game_directory,
@ -255,9 +258,9 @@ pub fn get_minecraft_arguments(
for x in legacy_arguments.split(' ') {
parsed_arguments.push(parse_minecraft_argument(
&x.replace(' ', TEMPORARY_REPLACE_CHAR),
&credentials.access_token,
&credentials.username,
credentials.id,
&access_token,
&profile.name,
profile.id,
version,
asset_index_name,
game_directory,

View File

@ -645,7 +645,8 @@ pub async fn launch_minecraft(
*resolution,
&java_version.architecture,
quick_play_type,
)?
)
.await?
.into_iter(),
)
.current_dir(instance_path.clone());
@ -655,7 +656,7 @@ pub async fn launch_minecraft(
if std::env::var("CARGO").is_ok() {
command.env_remove("DYLD_FALLBACK_LIBRARY_PATH");
}
// Java options should be set in instance options (the existence of _JAVA_OPTIONS overwites them)
// Java options should be set in instance options (the existence of _JAVA_OPTIONS overwrites them)
command.env_remove("_JAVA_OPTIONS");
command.envs(env_args);

View File

@ -19,6 +19,8 @@ use std::path::PathBuf;
use tokio::sync::Semaphore;
use uuid::Uuid;
use super::MinecraftProfile;
pub async fn migrate_legacy_data<'a, E>(exec: E) -> crate::Result<()>
where
E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy,
@ -117,13 +119,16 @@ where
.await
{
let minecraft_users_len = minecraft_auth.users.len();
for (uuid, credential) in minecraft_auth.users {
for (uuid, legacy_credentials) in minecraft_auth.users {
Credentials {
id: credential.id,
username: credential.username,
access_token: credential.access_token,
refresh_token: credential.refresh_token,
expires: credential.expires,
offline_profile: MinecraftProfile {
id: legacy_credentials.id,
name: legacy_credentials.username,
..MinecraftProfile::default()
},
access_token: legacy_credentials.access_token,
refresh_token: legacy_credentials.refresh_token,
expires: legacy_credentials.expires,
active: minecraft_auth.default_user == Some(uuid)
|| minecraft_users_len == 1,
}

View File

@ -5,25 +5,38 @@ 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::Response;
use reqwest::header::HeaderMap;
use reqwest::{Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
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::collections::hash_map::Entry;
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;
#[derive(Debug, Clone, Copy)]
pub enum MinecraftAuthStep {
GetDeviceToken,
SisuAuthenicate,
SisuAuthenticate,
GetOAuthToken,
RefreshOAuthToken,
SisuAuthorize,
@ -53,7 +66,7 @@ pub enum MinecraftAuthenticationError {
raw: String,
#[source]
source: serde_json::Error,
status_code: reqwest::StatusCode,
status_code: StatusCode,
},
#[error("Request failed during step {step:?}: {source}")]
Request {
@ -172,25 +185,43 @@ pub async fn login_finish(
minecraft_entitlements(&minecraft_token.access_token).await?;
let mut credentials = Credentials {
id: Uuid::default(),
username: String::default(),
offline_profile: MinecraftProfile::default(),
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,
};
credentials.get_profile().await?;
// During login, we need to fetch the online profile at least once to get the
// player UUID and name to use for the offline profile, in order for that offline
// profile to make sense. It's also important to modify the returned credentials
// object, as otherwise continued usage of it will skip the profile cache due to
// the dummy UUID
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, Clone, Debug)]
#[derive(Deserialize, Debug)]
pub struct Credentials {
pub id: Uuid,
pub username: String,
/// 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,
pub access_token: String,
pub refresh_token: String,
pub expires: DateTime<Utc>,
@ -198,10 +229,15 @@ pub struct Credentials {
}
impl Credentials {
/// Refreshes the authentication tokens for this user if they are expired.
async fn refresh(
&mut self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<()> {
if self.expires > Utc::now() {
return Ok(());
}
let oauth_token = oauth_refresh(&self.refresh_token).await?;
let (pair, current_date, _) =
DeviceTokenPair::refresh_and_get_device_token(
@ -235,22 +271,141 @@ impl Credentials {
self.expires = oauth_token.date
+ Duration::seconds(oauth_token.value.expires_in as i64);
self.get_profile().await?;
self.upsert(exec).await?;
Ok(())
}
async fn get_profile(&mut self) -> crate::Result<()> {
let profile = minecraft_profile(&self.access_token).await?;
/// Fetches the online profile for this user if possible.
///
/// Even if assuming a flawless network connection and Mojang backend, this may fail
/// if the current access token has expired. To ensure that does not happen, log in or
/// call [`refresh`](Self::refresh) before this method.
#[tracing::instrument(skip(self))]
pub async fn online_profile(&self) -> Option<Arc<MinecraftProfile>> {
enum ProfileCacheEntry {
Hit(Arc<MinecraftProfile>),
AuthErrorBackoff {
likely_expired_token: String,
last_attempt: Instant,
},
}
self.id = profile.id.unwrap_or_default();
self.username = profile.name;
/// 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.
static PROFILE_CACHE: Mutex<
HashMap<Uuid, ProfileCacheEntry, BuildHasherDefault<DefaultHasher>>,
> = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new()));
Ok(())
let mut profile_cache = PROFILE_CACHE.lock().await;
loop {
match profile_cache.entry(self.offline_profile.id) {
Entry::Occupied(entry) => {
match entry.get() {
ProfileCacheEntry::Hit(profile)
if profile.is_fresh() =>
{
return Some(Arc::clone(profile));
}
ProfileCacheEntry::Hit(_) => {
// The profile is stale, so remove it and try again
entry.remove();
continue;
}
// 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)
> std::time::Duration::from_secs(60) =>
{
entry.remove();
continue;
}
ProfileCacheEntry::AuthErrorBackoff { .. } => {
return None;
}
}
}
Entry::Vacant(entry) => {
match minecraft_profile(&self.access_token).await {
Ok(profile) => {
let profile = Arc::new(profile);
let cache_entry =
ProfileCacheEntry::Hit(Arc::clone(&profile));
// When fetching a profile for the first time, the player UUID may
// be unknown (i.e., set to a dummy value), so make sure we don't
// cache it in the wrong place
if entry.key() != &profile.id {
profile_cache.insert(profile.id, cache_entry);
} else {
entry.insert(cache_entry);
}
return 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
);
// We have to assume the player UUID key we have is correct here, which
// should always be the case assuming a non-adversarial server. In any
// case, any cache poisoning is inconsequential due to the entry expiration
// and the fact that we use at most one single dummy UUID
entry.insert(ProfileCacheEntry::AuthErrorBackoff {
likely_expired_token: self.access_token.clone(),
last_attempt: Instant::now(),
});
return None;
}
Err(err) => {
tracing::warn!(
"Failed to fetch online profile for UUID {}: {err}",
self.offline_profile.id
);
return 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,
@ -258,37 +413,35 @@ impl Credentials {
let credentials = Self::get_active(exec).await?;
if let Some(mut creds) = credentials {
if creds.expires < Utc::now() {
let res = creds.refresh(exec).await;
let res = creds.refresh(exec).await;
match res {
Ok(_) => Ok(Some(creds)),
Err(err) => {
if let ErrorKind::MinecraftAuthenticationError(
MinecraftAuthenticationError::Request {
ref source,
..
},
) = *err.raw
{
if source.is_connect() || source.is_timeout() {
return Ok(Some(creds));
}
match res {
Ok(_) => Ok(Some(creds)),
Err(err) => {
if let ErrorKind::MinecraftAuthenticationError(
MinecraftAuthenticationError::Request {
ref source,
..
},
) = *err.raw
{
if source.is_connect() || source.is_timeout() {
return Ok(Some(creds));
}
Err(err)
}
Err(err)
}
} else {
Ok(Some(creds))
}
} 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>,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Option<Self>> {
let res = sqlx::query!(
"
@ -301,21 +454,31 @@ impl Credentials {
.fetch_optional(exec)
.await?;
Ok(res.map(|x| Self {
id: Uuid::parse_str(&x.uuid).unwrap_or_default(),
username: x.username,
access_token: x.access_token,
refresh_token: x.refresh_token,
expires: Utc
.timestamp_opt(x.expires, 0)
.single()
.unwrap_or_else(Utc::now),
active: x.active == 1,
}))
Ok(match res {
Some(x) => {
let mut credentials = Self {
offline_profile: MinecraftProfile {
id: Uuid::parse_str(&x.uuid).unwrap_or_default(),
name: x.username,
..MinecraftProfile::default()
},
access_token: x.access_token,
refresh_token: x.refresh_token,
expires: Utc
.timestamp_opt(x.expires, 0)
.single()
.unwrap_or_else(Utc::now),
active: x.active == 1,
};
credentials.refresh(exec).await.ok();
Some(credentials)
}
None => None,
})
}
pub async fn get_all(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<DashMap<Uuid, Self>> {
let res = sqlx::query!(
"
@ -327,23 +490,27 @@ impl Credentials {
.fetch(exec)
.try_fold(DashMap::new(), |acc, x| {
let uuid = Uuid::parse_str(&x.uuid).unwrap_or_default();
acc.insert(
uuid,
Self {
let mut credentials = Self {
offline_profile: MinecraftProfile {
id: uuid,
username: x.username,
access_token: x.access_token,
refresh_token: x.refresh_token,
expires: Utc
.timestamp_opt(x.expires, 0)
.single()
.unwrap_or_else(Utc::now),
active: x.active == 1,
name: x.username,
..MinecraftProfile::default()
},
);
access_token: x.access_token,
refresh_token: x.refresh_token,
expires: Utc
.timestamp_opt(x.expires, 0)
.single()
.unwrap_or_else(Utc::now),
active: x.active == 1,
};
async move { Ok(acc) }
async move {
credentials.refresh(exec).await.ok();
acc.insert(uuid, credentials);
Ok(acc)
}
})
.await?;
@ -354,8 +521,9 @@ impl Credentials {
&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 = self.id.as_hyphenated().to_string();
let uuid = profile.id.as_hyphenated().to_string();
if self.active {
sqlx::query!(
@ -381,7 +549,7 @@ impl Credentials {
",
uuid,
self.active,
self.username,
profile.name,
self.access_token,
self.refresh_token,
expires,
@ -411,6 +579,46 @@ impl Credentials {
}
}
impl Serialize for Credentials {
fn serialize<S: Serializer>(
&self,
serializer: S,
) -> Result<S::Ok, S::Error> {
// 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 = 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", 5)?;
ser.serialize_field("profile", &*profile)?;
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.end()
}
}
pub struct DeviceTokenPair {
pub token: DeviceToken,
pub key: DeviceTokenKey,
@ -639,7 +847,7 @@ async fn sisu_authenticate(
"TitleId": "1794566092",
}),
key,
MinecraftAuthStep::SisuAuthenicate,
MinecraftAuthStep::SisuAuthenticate,
current_date,
)
.await?;
@ -911,13 +1119,157 @@ async fn minecraft_token(
})
}
#[derive(Deserialize)]
struct MinecraftProfile {
pub id: Option<Uuid>,
#[derive(Deserialize, Serialize, Debug, Copy, Clone)]
#[serde(rename_all(deserialize = "UPPERCASE"))]
pub enum MinecraftSkinModelVariant {
/// 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)]
#[serde(rename_all(deserialize = "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: Url,
/// 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<String>,
/// The player model variant this skin is for.
pub variant: MinecraftSkinModelVariant,
/// 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<String>,
}
fn normalize_skin_alias_case<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<String>, 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(<Option<Cow<'_, str>>>::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: Url,
/// The user-friendly name for the cape.
#[serde(rename = "alias")]
pub name: String,
}
#[tracing::instrument]
#[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<MinecraftSkin>,
/// The capes the player is known to have.
pub capes: Vec<MinecraftCape>,
/// The instant when the profile was fetched. See also [Self::is_fresh].
#[serde(skip)]
pub fetch_time: Option<Instant>,
}
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) -> bool {
self.fetch_time.is_some_and(|last_profile_fetch_time| {
Instant::now().saturating_duration_since(last_profile_fetch_time)
< std::time::Duration::from_secs(60)
})
}
}
pub enum MaybeOnlineMinecraftProfile<'profile> {
/// An online profile, fetched from the Mojang API.
Online(Arc<MinecraftProfile>),
/// 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<MinecraftProfile, MinecraftAuthenticationError> {
@ -926,6 +1278,9 @@ async fn minecraft_profile(
.get("https://api.minecraftservices.com/minecraft/profile")
.header("Accept", "application/json")
.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
@ -942,14 +1297,23 @@ async fn minecraft_profile(
}
})?;
serde_json::from_str(&text).map_err(|source| {
MinecraftAuthenticationError::DeserializeResponse {
source,
raw: text,
step: MinecraftAuthStep::MinecraftProfile,
status_code: status,
}
})
let mut profile =
serde_json::from_str::<MinecraftProfile>(&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)]
@ -967,7 +1331,7 @@ async fn minecraft_entitlements(
.bearer_auth(token)
.send()
})
.await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?;
.await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?;
let status = res.status();
let text = res.text().await.map_err(|source| {