Compare commits
5 Commits
main
...
alex/auth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f07bc86711 | ||
|
|
23f0c1dbf7 | ||
|
|
8abbc021ea | ||
|
|
ed8ff79809 | ||
|
|
f210de563d |
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -8889,6 +8889,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"fs4",
|
||||
"futures",
|
||||
"heck 0.5.0",
|
||||
"hickory-resolver",
|
||||
"indicatif",
|
||||
"notify",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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?;
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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| {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user