Compare commits
5 Commits
cal/fix-ja
...
alex/auth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f07bc86711 | ||
|
|
23f0c1dbf7 | ||
|
|
8abbc021ea | ||
|
|
ed8ff79809 | ||
|
|
f210de563d |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -8889,6 +8889,7 @@ dependencies = [
|
|||||||
"flate2",
|
"flate2",
|
||||||
"fs4",
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
|
"heck 0.5.0",
|
||||||
"hickory-resolver",
|
"hickory-resolver",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"notify",
|
"notify",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ flate2 = "1.1.2"
|
|||||||
fs4 = { version = "0.13.1", default-features = false }
|
fs4 = { version = "0.13.1", default-features = false }
|
||||||
futures = { version = "0.3.31", default-features = false }
|
futures = { version = "0.3.31", default-features = false }
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
|
heck = "0.5.0"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
hickory-resolver = "0.25.2"
|
hickory-resolver = "0.25.2"
|
||||||
hmac = "0.12.1"
|
hmac = "0.12.1"
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
size="36px"
|
size="36px"
|
||||||
:src="
|
:src="
|
||||||
selectedAccount
|
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'
|
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col w-full">
|
<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>
|
<span class="text-secondary text-xs">Minecraft account</span>
|
||||||
</div>
|
</div>
|
||||||
<DropdownIcon class="w-5 h-5 shrink-0" />
|
<DropdownIcon class="w-5 h-5 shrink-0" />
|
||||||
@@ -28,12 +28,17 @@
|
|||||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
||||||
>
|
>
|
||||||
<div v-if="selectedAccount" class="selected account">
|
<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>
|
<div>
|
||||||
<h4>{{ selectedAccount.username }}</h4>
|
<h4>{{ selectedAccount.profile.name }}</h4>
|
||||||
<p>Selected</p>
|
<p>Selected</p>
|
||||||
</div>
|
</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 />
|
<TrashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,12 +49,12 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
<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)">
|
<Button class="option account" @click="setAccount(account)">
|
||||||
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
|
<Avatar :src="`https://mc-heads.net/avatar/${account.profile.id}/128`" class="icon" />
|
||||||
<p>{{ account.username }}</p>
|
<p>{{ account.profile.name }}</p>
|
||||||
</Button>
|
</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 />
|
<TrashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,16 +106,16 @@ defineExpose({
|
|||||||
await refreshValues()
|
await refreshValues()
|
||||||
|
|
||||||
const displayAccounts = computed(() =>
|
const displayAccounts = computed(() =>
|
||||||
accounts.value.filter((account) => defaultUser.value !== account.id),
|
accounts.value.filter((account) => defaultUser.value !== account.profile.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedAccount = computed(() =>
|
const selectedAccount = computed(() =>
|
||||||
accounts.value.find((account) => account.id === defaultUser.value),
|
accounts.value.find((account) => account.profile.id === defaultUser.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function setAccount(account) {
|
async function setAccount(account) {
|
||||||
defaultUser.value = account.id
|
defaultUser.value = account.profile.id
|
||||||
await set_default_user(account.id).catch(handleError)
|
await set_default_user(account.profile.id).catch(handleError)
|
||||||
emit('change')
|
emit('change')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ async function loginMinecraft() {
|
|||||||
const loggedIn = await login_flow()
|
const loggedIn = await login_flow()
|
||||||
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
await set_default_user(loggedIn.id).catch(handleError)
|
await set_default_user(loggedIn.profile.id).catch(handleError)
|
||||||
}
|
}
|
||||||
|
|
||||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
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?;
|
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)
|
Ok(credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ fn main() {
|
|||||||
"profile_update_managed_modrinth_version",
|
"profile_update_managed_modrinth_version",
|
||||||
"profile_repair_managed_modrinth",
|
"profile_repair_managed_modrinth",
|
||||||
"profile_run",
|
"profile_run",
|
||||||
"profile_run_credentials",
|
|
||||||
"profile_kill",
|
"profile_kill",
|
||||||
"profile_edit",
|
"profile_edit",
|
||||||
"profile_edit_icon",
|
"profile_edit_icon",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
profile_update_managed_modrinth_version,
|
profile_update_managed_modrinth_version,
|
||||||
profile_repair_managed_modrinth,
|
profile_repair_managed_modrinth,
|
||||||
profile_run,
|
profile_run,
|
||||||
profile_run_credentials,
|
|
||||||
profile_kill,
|
profile_kill,
|
||||||
profile_edit,
|
profile_edit,
|
||||||
profile_edit_icon,
|
profile_edit_icon,
|
||||||
@@ -256,22 +255,6 @@ pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
|||||||
Ok(process)
|
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]
|
#[tauri::command]
|
||||||
pub async fn profile_kill(path: &str) -> Result<()> {
|
pub async fn profile_kill(path: &str) -> Result<()> {
|
||||||
profile::kill(path).await?;
|
profile::kill(path).await?;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] }
|
|||||||
tracing-error.workspace = true
|
tracing-error.workspace = true
|
||||||
|
|
||||||
paste.workspace = true
|
paste.workspace = true
|
||||||
|
heck.workspace = true
|
||||||
|
|
||||||
tauri = { workspace = true, optional = true }
|
tauri = { workspace = true, optional = true }
|
||||||
indicatif = { workspace = true, optional = true }
|
indicatif = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -39,21 +39,27 @@ pub struct LatestLogCursor {
|
|||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub struct CensoredString(String);
|
pub struct CensoredString(String);
|
||||||
impl CensoredString {
|
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();
|
let username = whoami::username();
|
||||||
s = s
|
s = s
|
||||||
.replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/")
|
.replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/")
|
||||||
.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
|
s = s
|
||||||
.replace(&credentials.access_token, "{MINECRAFT_ACCESS_TOKEN}")
|
.replace(&credentials.access_token, "{MINECRAFT_ACCESS_TOKEN}")
|
||||||
.replace(&credentials.username, "{MINECRAFT_USERNAME}")
|
|
||||||
.replace(
|
.replace(
|
||||||
&credentials.id.as_simple().to_string(),
|
&credentials.offline_profile.name,
|
||||||
|
"{MINECRAFT_USERNAME}",
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
&credentials.offline_profile.id.as_simple().to_string(),
|
||||||
"{MINECRAFT_UUID}",
|
"{MINECRAFT_UUID}",
|
||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
&credentials.id.as_hyphenated().to_string(),
|
&credentials.offline_profile.id.as_hyphenated().to_string(),
|
||||||
"{MINECRAFT_UUID}",
|
"{MINECRAFT_UUID}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -210,7 +216,7 @@ pub async fn get_output_by_filename(
|
|||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|x| x.1)
|
.map(|x| x.1)
|
||||||
.collect();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
// Load .gz file into String
|
// Load .gz file into String
|
||||||
if let Some(ext) = path.extension() {
|
if let Some(ext) = path.extension() {
|
||||||
@@ -350,7 +356,7 @@ pub async fn get_generic_live_log_cursor(
|
|||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|x| x.1)
|
.map(|x| x.1)
|
||||||
.collect();
|
.collect::<Vec<_>>();
|
||||||
let output = CensoredString::censor(output, &credentials);
|
let output = CensoredString::censor(output, &credentials);
|
||||||
Ok(LatestLogCursor {
|
Ok(LatestLogCursor {
|
||||||
cursor,
|
cursor,
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ pub async fn finish_login(
|
|||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
|
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
let users = Credentials::get_active(&state.pool).await?;
|
let user = Credentials::get_active(&state.pool).await?;
|
||||||
Ok(users.map(|x| x.id))
|
Ok(user.map(|user| user.offline_profile.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
|
|||||||
@@ -642,9 +642,8 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run Minecraft using a profile, and credentials for authentication
|
/// Run Minecraft using a profile, and credentials for authentication
|
||||||
/// Returns Arc pointer to RwLock to Child
|
|
||||||
#[tracing::instrument(skip(credentials))]
|
#[tracing::instrument(skip(credentials))]
|
||||||
pub async fn run_credentials(
|
async fn run_credentials(
|
||||||
path: &str,
|
path: &str,
|
||||||
credentials: &Credentials,
|
credentials: &Credentials,
|
||||||
quick_play_type: &QuickPlayType,
|
quick_play_type: &QuickPlayType,
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ fn parse_jvm_argument(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn get_minecraft_arguments(
|
pub async fn get_minecraft_arguments(
|
||||||
arguments: Option<&[Argument]>,
|
arguments: Option<&[Argument]>,
|
||||||
legacy_arguments: Option<&str>,
|
legacy_arguments: Option<&str>,
|
||||||
credentials: &Credentials,
|
credentials: &Credentials,
|
||||||
@@ -224,6 +224,9 @@ pub fn get_minecraft_arguments(
|
|||||||
java_arch: &str,
|
java_arch: &str,
|
||||||
quick_play_type: &QuickPlayType,
|
quick_play_type: &QuickPlayType,
|
||||||
) -> crate::Result<Vec<String>> {
|
) -> crate::Result<Vec<String>> {
|
||||||
|
let access_token = credentials.access_token.clone();
|
||||||
|
let profile = credentials.maybe_online_profile().await;
|
||||||
|
|
||||||
if let Some(arguments) = arguments {
|
if let Some(arguments) = arguments {
|
||||||
let mut parsed_arguments = Vec::new();
|
let mut parsed_arguments = Vec::new();
|
||||||
|
|
||||||
@@ -233,9 +236,9 @@ pub fn get_minecraft_arguments(
|
|||||||
|arg| {
|
|arg| {
|
||||||
parse_minecraft_argument(
|
parse_minecraft_argument(
|
||||||
arg,
|
arg,
|
||||||
&credentials.access_token,
|
&access_token,
|
||||||
&credentials.username,
|
&profile.name,
|
||||||
credentials.id,
|
profile.id,
|
||||||
version,
|
version,
|
||||||
asset_index_name,
|
asset_index_name,
|
||||||
game_directory,
|
game_directory,
|
||||||
@@ -255,9 +258,9 @@ pub fn get_minecraft_arguments(
|
|||||||
for x in legacy_arguments.split(' ') {
|
for x in legacy_arguments.split(' ') {
|
||||||
parsed_arguments.push(parse_minecraft_argument(
|
parsed_arguments.push(parse_minecraft_argument(
|
||||||
&x.replace(' ', TEMPORARY_REPLACE_CHAR),
|
&x.replace(' ', TEMPORARY_REPLACE_CHAR),
|
||||||
&credentials.access_token,
|
&access_token,
|
||||||
&credentials.username,
|
&profile.name,
|
||||||
credentials.id,
|
profile.id,
|
||||||
version,
|
version,
|
||||||
asset_index_name,
|
asset_index_name,
|
||||||
game_directory,
|
game_directory,
|
||||||
|
|||||||
@@ -645,7 +645,8 @@ pub async fn launch_minecraft(
|
|||||||
*resolution,
|
*resolution,
|
||||||
&java_version.architecture,
|
&java_version.architecture,
|
||||||
quick_play_type,
|
quick_play_type,
|
||||||
)?
|
)
|
||||||
|
.await?
|
||||||
.into_iter(),
|
.into_iter(),
|
||||||
)
|
)
|
||||||
.current_dir(instance_path.clone());
|
.current_dir(instance_path.clone());
|
||||||
@@ -655,7 +656,7 @@ pub async fn launch_minecraft(
|
|||||||
if std::env::var("CARGO").is_ok() {
|
if std::env::var("CARGO").is_ok() {
|
||||||
command.env_remove("DYLD_FALLBACK_LIBRARY_PATH");
|
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.env_remove("_JAVA_OPTIONS");
|
||||||
|
|
||||||
command.envs(env_args);
|
command.envs(env_args);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ use std::path::PathBuf;
|
|||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::MinecraftProfile;
|
||||||
|
|
||||||
pub async fn migrate_legacy_data<'a, E>(exec: E) -> crate::Result<()>
|
pub async fn migrate_legacy_data<'a, E>(exec: E) -> crate::Result<()>
|
||||||
where
|
where
|
||||||
E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy,
|
E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy,
|
||||||
@@ -117,13 +119,16 @@ where
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let minecraft_users_len = minecraft_auth.users.len();
|
let minecraft_users_len = minecraft_auth.users.len();
|
||||||
for (uuid, credential) in minecraft_auth.users {
|
for (uuid, legacy_credentials) in minecraft_auth.users {
|
||||||
Credentials {
|
Credentials {
|
||||||
id: credential.id,
|
offline_profile: MinecraftProfile {
|
||||||
username: credential.username,
|
id: legacy_credentials.id,
|
||||||
access_token: credential.access_token,
|
name: legacy_credentials.username,
|
||||||
refresh_token: credential.refresh_token,
|
..MinecraftProfile::default()
|
||||||
expires: credential.expires,
|
},
|
||||||
|
access_token: legacy_credentials.access_token,
|
||||||
|
refresh_token: legacy_credentials.refresh_token,
|
||||||
|
expires: legacy_credentials.expires,
|
||||||
active: minecraft_auth.default_user == Some(uuid)
|
active: minecraft_auth.default_user == Some(uuid)
|
||||||
|| minecraft_users_len == 1,
|
|| 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 chrono::{DateTime, Duration, TimeZone, Utc};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
|
use heck::ToTitleCase;
|
||||||
use p256::ecdsa::signature::Signer;
|
use p256::ecdsa::signature::Signer;
|
||||||
use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
|
use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
|
||||||
use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
|
use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use reqwest::Response;
|
|
||||||
use reqwest::header::HeaderMap;
|
use reqwest::header::HeaderMap;
|
||||||
|
use reqwest::{Response, StatusCode};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::ser::SerializeStruct;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::hash_map::Entry;
|
||||||
use std::future::Future;
|
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;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum MinecraftAuthStep {
|
pub enum MinecraftAuthStep {
|
||||||
GetDeviceToken,
|
GetDeviceToken,
|
||||||
SisuAuthenicate,
|
SisuAuthenticate,
|
||||||
GetOAuthToken,
|
GetOAuthToken,
|
||||||
RefreshOAuthToken,
|
RefreshOAuthToken,
|
||||||
SisuAuthorize,
|
SisuAuthorize,
|
||||||
@@ -53,7 +66,7 @@ pub enum MinecraftAuthenticationError {
|
|||||||
raw: String,
|
raw: String,
|
||||||
#[source]
|
#[source]
|
||||||
source: serde_json::Error,
|
source: serde_json::Error,
|
||||||
status_code: reqwest::StatusCode,
|
status_code: StatusCode,
|
||||||
},
|
},
|
||||||
#[error("Request failed during step {step:?}: {source}")]
|
#[error("Request failed during step {step:?}: {source}")]
|
||||||
Request {
|
Request {
|
||||||
@@ -172,25 +185,43 @@ pub async fn login_finish(
|
|||||||
minecraft_entitlements(&minecraft_token.access_token).await?;
|
minecraft_entitlements(&minecraft_token.access_token).await?;
|
||||||
|
|
||||||
let mut credentials = Credentials {
|
let mut credentials = Credentials {
|
||||||
id: Uuid::default(),
|
offline_profile: MinecraftProfile::default(),
|
||||||
username: String::default(),
|
|
||||||
access_token: minecraft_token.access_token,
|
access_token: minecraft_token.access_token,
|
||||||
refresh_token: oauth_token.value.refresh_token,
|
refresh_token: oauth_token.value.refresh_token,
|
||||||
expires: oauth_token.date
|
expires: oauth_token.date
|
||||||
+ Duration::seconds(oauth_token.value.expires_in as i64),
|
+ Duration::seconds(oauth_token.value.expires_in as i64),
|
||||||
active: true,
|
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?;
|
credentials.upsert(exec).await?;
|
||||||
|
|
||||||
Ok(credentials)
|
Ok(credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct Credentials {
|
pub struct Credentials {
|
||||||
pub id: Uuid,
|
/// The offline profile of the user these credentials are for.
|
||||||
pub username: String,
|
///
|
||||||
|
/// 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 access_token: String,
|
||||||
pub refresh_token: String,
|
pub refresh_token: String,
|
||||||
pub expires: DateTime<Utc>,
|
pub expires: DateTime<Utc>,
|
||||||
@@ -198,10 +229,15 @@ pub struct Credentials {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Credentials {
|
impl Credentials {
|
||||||
|
/// Refreshes the authentication tokens for this user if they are expired.
|
||||||
async fn refresh(
|
async fn refresh(
|
||||||
&mut self,
|
&mut self,
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
|
if self.expires > Utc::now() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let oauth_token = oauth_refresh(&self.refresh_token).await?;
|
let oauth_token = oauth_refresh(&self.refresh_token).await?;
|
||||||
let (pair, current_date, _) =
|
let (pair, current_date, _) =
|
||||||
DeviceTokenPair::refresh_and_get_device_token(
|
DeviceTokenPair::refresh_and_get_device_token(
|
||||||
@@ -235,22 +271,141 @@ impl Credentials {
|
|||||||
self.expires = oauth_token.date
|
self.expires = oauth_token.date
|
||||||
+ Duration::seconds(oauth_token.value.expires_in as i64);
|
+ Duration::seconds(oauth_token.value.expires_in as i64);
|
||||||
|
|
||||||
self.get_profile().await?;
|
|
||||||
|
|
||||||
self.upsert(exec).await?;
|
self.upsert(exec).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_profile(&mut self) -> crate::Result<()> {
|
/// Fetches the online profile for this user if possible.
|
||||||
let profile = minecraft_profile(&self.access_token).await?;
|
///
|
||||||
|
/// Even if assuming a flawless network connection and Mojang backend, this may fail
|
||||||
self.id = profile.id.unwrap_or_default();
|
/// if the current access token has expired. To ensure that does not happen, log in or
|
||||||
self.username = profile.name;
|
/// call [`refresh`](Self::refresh) before this method.
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
Ok(())
|
pub async fn online_profile(&self) -> Option<Arc<MinecraftProfile>> {
|
||||||
|
enum ProfileCacheEntry {
|
||||||
|
Hit(Arc<MinecraftProfile>),
|
||||||
|
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.
|
||||||
|
static PROFILE_CACHE: Mutex<
|
||||||
|
HashMap<Uuid, ProfileCacheEntry, BuildHasherDefault<DefaultHasher>>,
|
||||||
|
> = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new()));
|
||||||
|
|
||||||
|
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]
|
#[tracing::instrument]
|
||||||
pub async fn get_default_credential(
|
pub async fn get_default_credential(
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||||
@@ -258,7 +413,6 @@ impl Credentials {
|
|||||||
let credentials = Self::get_active(exec).await?;
|
let credentials = Self::get_active(exec).await?;
|
||||||
|
|
||||||
if let Some(mut creds) = credentials {
|
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 {
|
match res {
|
||||||
@@ -279,16 +433,15 @@ impl Credentials {
|
|||||||
Err(err)
|
Err(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Ok(Some(creds))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches the currently selected credentials from the database, attempting
|
||||||
|
/// to refresh them if they are expired.
|
||||||
pub async fn get_active(
|
pub async fn get_active(
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||||
) -> crate::Result<Option<Self>> {
|
) -> crate::Result<Option<Self>> {
|
||||||
let res = sqlx::query!(
|
let res = sqlx::query!(
|
||||||
"
|
"
|
||||||
@@ -301,9 +454,14 @@ impl Credentials {
|
|||||||
.fetch_optional(exec)
|
.fetch_optional(exec)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(res.map(|x| Self {
|
Ok(match res {
|
||||||
|
Some(x) => {
|
||||||
|
let mut credentials = Self {
|
||||||
|
offline_profile: MinecraftProfile {
|
||||||
id: Uuid::parse_str(&x.uuid).unwrap_or_default(),
|
id: Uuid::parse_str(&x.uuid).unwrap_or_default(),
|
||||||
username: x.username,
|
name: x.username,
|
||||||
|
..MinecraftProfile::default()
|
||||||
|
},
|
||||||
access_token: x.access_token,
|
access_token: x.access_token,
|
||||||
refresh_token: x.refresh_token,
|
refresh_token: x.refresh_token,
|
||||||
expires: Utc
|
expires: Utc
|
||||||
@@ -311,11 +469,16 @@ impl Credentials {
|
|||||||
.single()
|
.single()
|
||||||
.unwrap_or_else(Utc::now),
|
.unwrap_or_else(Utc::now),
|
||||||
active: x.active == 1,
|
active: x.active == 1,
|
||||||
}))
|
};
|
||||||
|
credentials.refresh(exec).await.ok();
|
||||||
|
Some(credentials)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_all(
|
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>> {
|
) -> crate::Result<DashMap<Uuid, Self>> {
|
||||||
let res = sqlx::query!(
|
let res = sqlx::query!(
|
||||||
"
|
"
|
||||||
@@ -327,12 +490,12 @@ impl Credentials {
|
|||||||
.fetch(exec)
|
.fetch(exec)
|
||||||
.try_fold(DashMap::new(), |acc, x| {
|
.try_fold(DashMap::new(), |acc, x| {
|
||||||
let uuid = Uuid::parse_str(&x.uuid).unwrap_or_default();
|
let uuid = Uuid::parse_str(&x.uuid).unwrap_or_default();
|
||||||
|
let mut credentials = Self {
|
||||||
acc.insert(
|
offline_profile: MinecraftProfile {
|
||||||
uuid,
|
|
||||||
Self {
|
|
||||||
id: uuid,
|
id: uuid,
|
||||||
username: x.username,
|
name: x.username,
|
||||||
|
..MinecraftProfile::default()
|
||||||
|
},
|
||||||
access_token: x.access_token,
|
access_token: x.access_token,
|
||||||
refresh_token: x.refresh_token,
|
refresh_token: x.refresh_token,
|
||||||
expires: Utc
|
expires: Utc
|
||||||
@@ -340,10 +503,14 @@ impl Credentials {
|
|||||||
.single()
|
.single()
|
||||||
.unwrap_or_else(Utc::now),
|
.unwrap_or_else(Utc::now),
|
||||||
active: x.active == 1,
|
active: x.active == 1,
|
||||||
},
|
};
|
||||||
);
|
|
||||||
|
|
||||||
async move { Ok(acc) }
|
async move {
|
||||||
|
credentials.refresh(exec).await.ok();
|
||||||
|
acc.insert(uuid, credentials);
|
||||||
|
|
||||||
|
Ok(acc)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -354,8 +521,9 @@ impl Credentials {
|
|||||||
&self,
|
&self,
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
|
let profile = self.maybe_online_profile().await;
|
||||||
let expires = self.expires.timestamp();
|
let expires = self.expires.timestamp();
|
||||||
let uuid = self.id.as_hyphenated().to_string();
|
let uuid = profile.id.as_hyphenated().to_string();
|
||||||
|
|
||||||
if self.active {
|
if self.active {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
@@ -381,7 +549,7 @@ impl Credentials {
|
|||||||
",
|
",
|
||||||
uuid,
|
uuid,
|
||||||
self.active,
|
self.active,
|
||||||
self.username,
|
profile.name,
|
||||||
self.access_token,
|
self.access_token,
|
||||||
self.refresh_token,
|
self.refresh_token,
|
||||||
expires,
|
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 struct DeviceTokenPair {
|
||||||
pub token: DeviceToken,
|
pub token: DeviceToken,
|
||||||
pub key: DeviceTokenKey,
|
pub key: DeviceTokenKey,
|
||||||
@@ -639,7 +847,7 @@ async fn sisu_authenticate(
|
|||||||
"TitleId": "1794566092",
|
"TitleId": "1794566092",
|
||||||
}),
|
}),
|
||||||
key,
|
key,
|
||||||
MinecraftAuthStep::SisuAuthenicate,
|
MinecraftAuthStep::SisuAuthenticate,
|
||||||
current_date,
|
current_date,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -911,13 +1119,157 @@ async fn minecraft_token(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, Serialize, Debug, Copy, Clone)]
|
||||||
struct MinecraftProfile {
|
#[serde(rename_all(deserialize = "UPPERCASE"))]
|
||||||
pub id: Option<Uuid>,
|
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,
|
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(
|
async fn minecraft_profile(
|
||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<MinecraftProfile, MinecraftAuthenticationError> {
|
) -> Result<MinecraftProfile, MinecraftAuthenticationError> {
|
||||||
@@ -926,6 +1278,9 @@ async fn minecraft_profile(
|
|||||||
.get("https://api.minecraftservices.com/minecraft/profile")
|
.get("https://api.minecraftservices.com/minecraft/profile")
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.bearer_auth(token)
|
.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()
|
.send()
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -942,14 +1297,23 @@ async fn minecraft_profile(
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
serde_json::from_str(&text).map_err(|source| {
|
let mut profile =
|
||||||
|
serde_json::from_str::<MinecraftProfile>(&text).map_err(|source| {
|
||||||
MinecraftAuthenticationError::DeserializeResponse {
|
MinecraftAuthenticationError::DeserializeResponse {
|
||||||
source,
|
source,
|
||||||
raw: text,
|
raw: text,
|
||||||
step: MinecraftAuthStep::MinecraftProfile,
|
step: MinecraftAuthStep::MinecraftProfile,
|
||||||
status_code: status,
|
status_code: status,
|
||||||
}
|
}
|
||||||
})
|
})?;
|
||||||
|
profile.fetch_time = Some(Instant::now());
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Successfully fetched Minecraft profile for {}",
|
||||||
|
profile.name
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
Reference in New Issue
Block a user