Files
Modrinth-Enhanced/patches/0006-Add-Ely.by-accounts.patch
T

855 lines
29 KiB
Diff

From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Mon, 14 Sep 2026 13:24:41 +0200
Subject: [PATCH] Add Ely.by accounts
Ely.by is an alternative Minecraft account system. "Add Ely.by account"
sits next to the Microsoft and offline options in the account card and
asks for the account name and password. A two-factor code is appended
to the password after a colon, which is Ely.by's own convention.
Minecraft asks Mojang who the player is, so an Ely.by account cannot
start the game on its own. The authlib-injector agent points those calls
at Ely.by instead; it is downloaded once and cached, after which such an
account launches with no network at all.
Like offline accounts, an Ely.by account is a row in `minecraft_users`
rather than a table of its own, so no migration is needed: the client
token Ely.by issues alongside the access token lives behind a marker in
the refresh token column, which is both what renews the pair and what
identifies the account. The expiry column becomes "check again after" -
Ely.by does not say when its tokens expire - so the pair is validated
and, if needed, renewed a few times a day instead of on every read of
the account list.
Signing in on Ely.by's own page would be better than a password form,
and needs an OAuth application registered with Ely.by; there is none for
Modrinth Enhanced yet.
---
.../src/components/ui/AccountsCard.vue | 30 ++-
.../src/components/ui/ElyAccountModal.vue | 168 ++++++++++++++
.../MinecraftRequiredModal.vue | 29 ++-
apps/app-frontend/src/helpers/auth.js | 15 ++
apps/app/build.rs | 1 +
apps/app/src/api/auth.rs | 10 +
packages/app-lib/src/api/minecraft_auth.rs | 34 +++
packages/app-lib/src/launcher/mod.rs | 16 ++
packages/app-lib/src/state/minecraft_auth.rs | 213 +++++++++++++++++-
packages/app-lib/src/util/authlib_injector.rs | 77 +++++++
packages/app-lib/src/util/mod.rs | 1 +
11 files changed, 581 insertions(+), 13 deletions(-)
create mode 100644 apps/app-frontend/src/components/ui/ElyAccountModal.vue
create mode 100644 packages/app-lib/src/util/authlib_injector.rs
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
index ebc92c1..35c21fa 100644
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
@@ -9,6 +9,10 @@
<SpinnerIcon v-else class="animate-spin" />
{{ formatMessage(messages.signInToMinecraft) }}
</Button>
+ <Button @click="elyAccountModal?.show($event)">
+ <KeyIcon />
+ {{ formatMessage(messages.addElyAccount) }}
+ </Button>
<Button @click="offlineAccountModal?.show($event)">
<UserIcon />
{{ formatMessage(messages.addOfflineAccount) }}
@@ -84,6 +88,13 @@
<PlusIcon />
{{ formatMessage(messages.addAccount) }}
</Button>
+ <Button
+ class="w-full !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)]"
+ @click="elyAccountModal?.show($event)"
+ >
+ <KeyIcon />
+ {{ formatMessage(messages.addElyAccount) }}
+ </Button>
<Button
class="w-full !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)]"
@click="offlineAccountModal?.show($event)"
@@ -94,11 +105,13 @@
</div>
</div>
</Accordion>
- <OfflineAccountModal ref="offlineAccountModal" @created="offlineAccountCreated" />
+ <OfflineAccountModal ref="offlineAccountModal" @created="accountAdded" />
+ <ElyAccountModal ref="elyAccountModal" @created="accountAdded" />
</template>
<script setup lang="ts">
import {
+ KeyIcon,
LogInIcon,
PlusIcon,
RadioButtonCheckedIcon,
@@ -119,6 +132,7 @@ import {
import type { Ref } from 'vue'
import { computed, onUnmounted, ref } from 'vue'
+import ElyAccountModal from '@/components/ui/ElyAccountModal.vue'
import OfflineAccountModal from '@/components/ui/OfflineAccountModal.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
@@ -150,6 +164,7 @@ type MinecraftCredential = {
const accounts: Ref<MinecraftCredential[]> = ref([])
const offlineAccountModal = ref<InstanceType<typeof OfflineAccountModal>>()
+const elyAccountModal = ref<InstanceType<typeof ElyAccountModal>>()
const loginDisabled = ref(false)
const defaultUser = ref<string | undefined>()
const equippedSkin = ref<Skin | null>(null)
@@ -206,9 +221,14 @@ function showOfflineAccountModal(event?: MouseEvent) {
offlineAccountModal.value?.show(event)
}
+function showElyAccountModal(event?: MouseEvent) {
+ elyAccountModal.value?.show(event)
+}
+
defineExpose({
refreshValues,
showOfflineAccountModal,
+ showElyAccountModal,
setEquippedSkin,
setLoginDisabled,
login,
@@ -267,8 +287,8 @@ async function login() {
loginDisabled.value = false
}
-async function offlineAccountCreated() {
- // `login_offline` already marks the new account as the active one.
+async function accountAdded() {
+ // Both sign-in paths already mark the new account as the active one.
await refreshValues()
emit('change')
}
@@ -303,6 +323,10 @@ const messages = defineMessages({
id: 'minecraft-account.add-offline-account',
defaultMessage: 'Add offline account',
},
+ addElyAccount: {
+ id: 'minecraft-account.add-ely-account',
+ defaultMessage: 'Add Ely.by account',
+ },
removeAccount: {
id: 'minecraft-account.remove-account',
defaultMessage: 'Remove account',
diff --git a/apps/app-frontend/src/components/ui/ElyAccountModal.vue b/apps/app-frontend/src/components/ui/ElyAccountModal.vue
new file mode 100644
index 0000000..c589375
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/ElyAccountModal.vue
@@ -0,0 +1,168 @@
+<template>
+ <NewModal ref="modal" :header="formatMessage(messages.header)" max-width="480px" width="100%">
+ <div class="flex flex-col gap-4">
+ <p class="m-0 leading-tight text-secondary">
+ {{ formatMessage(messages.description) }}
+ </p>
+
+ <form class="flex flex-col gap-3" @submit.prevent="submit">
+ <div class="flex flex-col gap-2">
+ <label class="font-semibold text-contrast" for="ely-account-username">
+ {{ formatMessage(messages.usernameLabel) }}
+ </label>
+ <Input
+ id="ely-account-username"
+ v-model="username"
+ :icon="UserIcon"
+ :placeholder="formatMessage(messages.usernamePlaceholder)"
+ :error="!!error"
+ autocapitalize="none"
+ autocorrect="off"
+ :spellcheck="false"
+ class="w-full"
+ />
+ </div>
+
+ <div class="flex flex-col gap-2">
+ <label class="font-semibold text-contrast" for="ely-account-password">
+ {{ formatMessage(messages.passwordLabel) }}
+ </label>
+ <Input
+ id="ely-account-password"
+ v-model="password"
+ type="password"
+ :icon="KeyIcon"
+ :error="!!error"
+ class="w-full"
+ />
+ <p class="m-0 text-sm leading-tight text-secondary">
+ {{ formatMessage(messages.twoFactorHint) }}
+ </p>
+ </div>
+
+ <p v-if="error" class="m-0 text-sm leading-tight text-red">{{ error }}</p>
+ </form>
+ </div>
+
+ <template #actions>
+ <div class="flex justify-end gap-2">
+ <Button native-type="button" @click="modal?.hide()">
+ <XIcon aria-hidden="true" />
+ {{ formatMessage(commonMessages.cancelButton) }}
+ </Button>
+ <Button
+ type="colored"
+ color="brand"
+ native-type="button"
+ :disabled="submitting || !username.trim() || !password"
+ @click="submit"
+ >
+ <SpinnerIcon v-if="submitting" aria-hidden="true" class="animate-spin" />
+ <LogInIcon v-else aria-hidden="true" />
+ {{ formatMessage(messages.signInButton) }}
+ </Button>
+ </div>
+ </template>
+ </NewModal>
+</template>
+
+<script setup lang="ts">
+import { KeyIcon, LogInIcon, SpinnerIcon, UserIcon, XIcon } from '@modrinth/assets'
+import {
+ Button,
+ commonMessages,
+ defineMessages,
+ Input,
+ NewModal,
+ useVIntl,
+} from '@modrinth/ui'
+import { nextTick, ref } from 'vue'
+
+import { login_ely } from '@/helpers/auth'
+
+const { formatMessage } = useVIntl()
+
+const emit = defineEmits<{
+ created: [account: unknown]
+}>()
+
+const modal = ref<InstanceType<typeof NewModal>>()
+const username = ref('')
+const password = ref('')
+const error = ref('')
+const submitting = ref(false)
+
+function show(event?: MouseEvent) {
+ username.value = ''
+ password.value = ''
+ error.value = ''
+ submitting.value = false
+ modal.value?.show(event)
+ void nextTick(() => {
+ document.getElementById('ely-account-username')?.focus()
+ })
+}
+
+async function submit() {
+ if (submitting.value) return
+
+ const name = username.value.trim()
+ if (!name || !password.value) return
+
+ submitting.value = true
+ error.value = ''
+
+ try {
+ const account = await login_ely(name, password.value)
+ password.value = ''
+ modal.value?.hide()
+ emit('created', account)
+ } catch (e) {
+ const text =
+ typeof e === 'string' ? e : ((e as Error)?.message ?? formatMessage(messages.genericError))
+ // The launcher's own label for the kind of error says nothing here.
+ error.value = text.replace(/^(Error|Invalid input): /, '')
+ } finally {
+ submitting.value = false
+ }
+}
+
+defineExpose({ show })
+
+const messages = defineMessages({
+ header: {
+ id: 'app.ely-account.header',
+ defaultMessage: 'Sign in with Ely.by',
+ },
+ description: {
+ id: 'app.ely-account.description',
+ defaultMessage:
+ 'Ely.by is an alternative Minecraft account system. Its accounts can play singleplayer and join any server that accepts Ely.by.',
+ },
+ usernameLabel: {
+ id: 'app.ely-account.username-label',
+ defaultMessage: 'Account name or email',
+ },
+ usernamePlaceholder: {
+ id: 'app.ely-account.username-placeholder',
+ defaultMessage: 'Your Ely.by account',
+ },
+ passwordLabel: {
+ id: 'app.ely-account.password-label',
+ defaultMessage: 'Password',
+ },
+ twoFactorHint: {
+ id: 'app.ely-account.two-factor-hint',
+ defaultMessage:
+ 'With two-factor authentication on, append your current code to the password, separated by a colon.',
+ },
+ signInButton: {
+ id: 'app.ely-account.sign-in-button',
+ defaultMessage: 'Sign in',
+ },
+ genericError: {
+ id: 'app.ely-account.generic-error',
+ defaultMessage: 'Could not sign in to Ely.by.',
+ },
+})
+</script>
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
index 8e6b275..7781cee 100644
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
@@ -47,10 +47,16 @@
<p class="m-0 text-sm leading-tight text-secondary">
{{ formatMessage(messages.offlineHint) }}
</p>
- <Button @click="addOfflineAccount">
- <UserIcon />
- {{ formatMessage(messages.addOfflineAccount) }}
- </Button>
+ <div class="grid grid-cols-2 gap-2">
+ <Button @click="addElyAccount">
+ <KeyIcon />
+ {{ formatMessage(messages.addElyAccount) }}
+ </Button>
+ <Button @click="addOfflineAccount">
+ <UserIcon />
+ {{ formatMessage(messages.addOfflineAccount) }}
+ </Button>
+ </div>
</div>
<p class="m-0 text-center text-sm text-secondary">
@@ -67,7 +73,7 @@
</template>
<script setup lang="ts">
-import { MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
+import { KeyIcon, MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { inject, type Ref, ref } from 'vue'
@@ -113,11 +119,15 @@ const messages = defineMessages({
offlineHint: {
id: 'minecraft-required.offline-hint',
defaultMessage:
- 'Or play singleplayer and offline-mode servers without an account of any kind.',
+ 'Or sign in with Ely.by, or play singleplayer and offline-mode servers without an account of any kind.',
+ },
+ addElyAccount: {
+ id: 'minecraft-required.add-ely-account',
+ defaultMessage: 'Ely.by account',
},
addOfflineAccount: {
id: 'minecraft-required.add-offline-account',
- defaultMessage: 'Add offline account',
+ defaultMessage: 'Offline account',
},
})
@@ -151,6 +161,11 @@ function addOfflineAccount(event: MouseEvent) {
accountsCard.value?.showOfflineAccountModal(event)
}
+function addElyAccount(event: MouseEvent) {
+ modal.value?.hide()
+ accountsCard.value?.showElyAccountModal(event)
+}
+
defineExpose({
show,
})
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
index cb7319a..57580ff 100644
--- a/apps/app-frontend/src/helpers/auth.js
+++ b/apps/app-frontend/src/helpers/auth.js
@@ -46,6 +46,21 @@ export async function login_offline(username) {
return await invoke('plugin:auth|login_offline', { username })
}
+/**
+ * Signs in to Ely.by and makes that account the active one.
+ *
+ * Ely.by is an alternative Minecraft account system. The game is pointed at it
+ * with the authlib-injector agent at launch.
+ *
+ * @param {string} username Ely.by account name or email
+ * @param {string} password Ely.by password, with `:code` appended when the
+ * account has two-factor authentication enabled
+ * @returns {Promise<Credential>}
+ */
+export async function login_ely(username, password) {
+ return await invoke('plugin:auth|login_ely', { username, password })
+}
+
/**
* Retrieves the default user
* @return {Promise<UUID | undefined>}
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 7806b4b..24db197 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -15,6 +15,7 @@ fn main() {
"check_reachable",
"login",
"login_offline",
+ "login_ely",
"remove_user",
"get_default_user",
"set_default_user",
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
index f4eded6..dea07b2 100644
--- a/apps/app/src/api/auth.rs
+++ b/apps/app/src/api/auth.rs
@@ -10,6 +10,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
check_reachable,
login,
login_offline,
+ login_ely,
remove_user,
get_default_user,
set_default_user,
@@ -93,6 +94,15 @@ pub async fn login_offline(username: String) -> Result<Credentials> {
Ok(minecraft_auth::login_offline(&username).await?)
}
+/// Signs in to Ely.by and makes that account active.
+#[tauri::command]
+pub async fn login_ely(
+ username: String,
+ password: String,
+) -> Result<Credentials> {
+ Ok(minecraft_auth::login_ely(&username, &password).await?)
+}
+
#[tauri::command]
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::remove_user(user).await?)
diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs
index a7fac4a..2d18da3 100644
--- a/packages/app-lib/src/api/minecraft_auth.rs
+++ b/packages/app-lib/src/api/minecraft_auth.rs
@@ -86,6 +86,40 @@ pub async fn login_offline(username: &str) -> crate::Result<Credentials> {
Ok(credentials)
}
+/// Signs in to Ely.by and makes the account the active one.
+///
+/// Ely.by is an alternative account system for Minecraft. The game is pointed
+/// at it with the authlib-injector agent at launch, so such an account can play
+/// singleplayer and join any server that accepts Ely.by.
+#[tracing::instrument(skip(password))]
+pub async fn login_ely(
+ username: &str,
+ password: &str,
+) -> crate::Result<Credentials> {
+ let username = username.trim();
+
+ if username.is_empty() || password.is_empty() {
+ return Err(crate::ErrorKind::InputError(
+ "An Ely.by account name and password are both required".to_string(),
+ )
+ .into());
+ }
+
+ let state = State::get().await?;
+ let credentials = Credentials::ely(username, password).await?;
+ credentials.upsert(&state.pool).await?;
+
+ if let Err(error) =
+ crate::onboarding_checklist::mark_logged_into_minecraft().await
+ {
+ tracing::warn!(
+ "Failed to mark Minecraft login in onboarding checklist: {error}"
+ );
+ }
+
+ Ok(credentials)
+}
+
#[tracing::instrument]
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
let state = State::get().await?;
diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs
index 0d5c08d..fd11a46 100644
--- a/packages/app-lib/src/launcher/mod.rs
+++ b/packages/app-lib/src/launcher/mod.rs
@@ -1133,6 +1133,22 @@ pub async fn launch_minecraft(
command.arg("--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED");
}
+ // Minecraft asks Mojang who the player is, and an Ely.by account is not a
+ // Mojang account. authlib-injector is a Java agent that points those calls
+ // at Ely.by instead, and without it such an account cannot start the game.
+ if credentials.is_ely() {
+ let injector = crate::util::authlib_injector::get_authlib_injector(
+ &state.directories,
+ )
+ .await?;
+
+ command.arg(format!(
+ "-javaagent:{}={}",
+ injector.to_string_lossy(),
+ crate::state::ELY_API_ROOT
+ ));
+ }
+
command
.arg("com.modrinth.theseus.MinecraftLaunch")
.arg(version_info.main_class.clone())
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
index 14455ec..4130488 100644
--- a/packages/app-lib/src/state/minecraft_auth.rs
+++ b/packages/app-lib/src/state/minecraft_auth.rs
@@ -239,6 +239,67 @@ pub fn offline_uuid(username: &str) -> Uuid {
Uuid::from_bytes(bytes)
}
+/// Marker stored in front of an Ely.by account's client token.
+///
+/// Ely.by's Yggdrasil flow hands back an access token and a client token, and
+/// renewing the pair needs both. The client token therefore goes in the refresh
+/// token column behind this marker, which both identifies the account as an
+/// Ely.by one and keeps it out of a table of its own.
+const ELY_REFRESH_TOKEN_PREFIX: &str = "modrinth-enhanced:ely:";
+
+/// Ely.by's Yggdrasil server, which answers the same shapes Mojang's used to.
+const ELY_AUTHSERVER: &str = "https://authserver.ely.by";
+
+/// What authlib-injector is handed so the game asks Ely.by rather than Mojang.
+///
+/// The agent resolves the short form to Ely.by's actual API root itself.
+pub const ELY_API_ROOT: &str = "ely.by";
+
+#[derive(Deserialize)]
+struct ElyAuthResponse {
+ #[serde(rename = "accessToken")]
+ access_token: String,
+ #[serde(rename = "clientToken")]
+ client_token: String,
+ #[serde(rename = "selectedProfile")]
+ selected_profile: ElyProfile,
+}
+
+#[derive(Deserialize)]
+struct ElyProfile {
+ id: String,
+ name: String,
+}
+
+/// Reads the error message out of an Ely.by refusal, falling back to the status.
+async fn ely_error(response: Response) -> crate::Error {
+ #[derive(Deserialize)]
+ struct ElyError {
+ #[serde(rename = "errorMessage")]
+ error_message: Option<String>,
+ }
+
+ let status = response.status();
+ let message = response
+ .json::<ElyError>()
+ .await
+ .ok()
+ .and_then(|error| error.error_message)
+ .unwrap_or_else(|| format!("Ely.by refused the request ({status})"));
+
+ crate::ErrorKind::OtherError(message).as_error()
+}
+
+/// Ely.by's Yggdrasil UUIDs come without dashes.
+fn parse_ely_uuid(id: &str) -> crate::Result<Uuid> {
+ Uuid::parse_str(id).map_err(|_| {
+ crate::ErrorKind::OtherError(format!(
+ "Ely.by returned a player id that could not be read: {id}"
+ ))
+ .as_error()
+ })
+}
+
/// An entry in the player profile cache, keyed by player UUID.
pub(super) enum ProfileCacheEntry {
/// A cached profile that is valid, even though it may be stale.
@@ -319,6 +380,135 @@ impl Credentials {
self.refresh_token == OFFLINE_REFRESH_TOKEN
}
+ /// Whether these credentials belong to an Ely.by account.
+ pub fn is_ely(&self) -> bool {
+ self.refresh_token.starts_with(ELY_REFRESH_TOKEN_PREFIX)
+ }
+
+ /// The client token Ely.by issued with the access token, if this is an
+ /// Ely.by account.
+ fn ely_client_token(&self) -> Option<&str> {
+ self.refresh_token.strip_prefix(ELY_REFRESH_TOKEN_PREFIX)
+ }
+
+ /// Signs in to Ely.by with an account name or email and a password.
+ ///
+ /// Ely.by accounts with two-factor authentication expect the current code
+ /// appended to the password with a colon, which is Ely.by's own convention
+ /// and is passed straight through.
+ pub async fn ely(username: &str, password: &str) -> crate::Result<Self> {
+ let client_token = Uuid::new_v4().to_string();
+
+ let response = INSECURE_REQWEST_CLIENT
+ .post(format!("{ELY_AUTHSERVER}/auth/authenticate"))
+ .json(&json!({
+ "username": username,
+ "password": password,
+ "clientToken": client_token,
+ "requestUser": false,
+ "agent": { "name": "Minecraft", "version": 1 },
+ }))
+ .send()
+ .await
+ .map_err(|error| {
+ crate::ErrorKind::OtherError(format!(
+ "Could not reach Ely.by: {error}"
+ ))
+ })?;
+
+ if !response.status().is_success() {
+ return Err(ely_error(response).await);
+ }
+
+ let auth = response.json::<ElyAuthResponse>().await?;
+
+ Ok(Self {
+ offline_profile: MinecraftProfile {
+ id: parse_ely_uuid(&auth.selected_profile.id)?,
+ name: auth.selected_profile.name,
+ ..MinecraftProfile::default()
+ },
+ access_token: auth.access_token,
+ refresh_token: format!(
+ "{ELY_REFRESH_TOKEN_PREFIX}{}",
+ auth.client_token
+ ),
+ // Ely.by does not say when its token expires. The expiry column is
+ // used as "check again after" instead, so the launcher asks Ely.by
+ // about the token a few times a day rather than on every read of
+ // the account list.
+ expires: Utc::now() + Duration::hours(6),
+ active: true,
+ })
+ }
+
+ /// Renews an Ely.by token pair, returning whether it is now usable.
+ ///
+ /// Ely.by refuses a pair that has been invalidated elsewhere - signing in
+ /// from another launcher does that - and there is no way back from it
+ /// without the password, so the caller is told rather than the launch
+ /// failing on its own later.
+ async fn refresh_ely(&mut self) -> crate::Result<bool> {
+ let Some(client_token) = self.ely_client_token() else {
+ return Ok(false);
+ };
+
+ let response = INSECURE_REQWEST_CLIENT
+ .post(format!("{ELY_AUTHSERVER}/auth/refresh"))
+ .json(&json!({
+ "accessToken": &self.access_token,
+ "clientToken": client_token,
+ "requestUser": false,
+ }))
+ .send()
+ .await;
+
+ let response = match response {
+ Ok(response) => response,
+ // Ely.by being unreachable says nothing about the token. Leave it
+ // alone: an offline launch with a still-valid token works.
+ Err(error) => {
+ tracing::warn!("Could not reach Ely.by to refresh: {error}");
+ return Ok(true);
+ }
+ };
+
+ if response.status().is_server_error() {
+ return Ok(true);
+ }
+
+ if !response.status().is_success() {
+ return Ok(false);
+ }
+
+ let auth = response.json::<ElyAuthResponse>().await?;
+ self.access_token = auth.access_token;
+ self.refresh_token =
+ format!("{ELY_REFRESH_TOKEN_PREFIX}{}", auth.client_token);
+ self.offline_profile = MinecraftProfile {
+ id: parse_ely_uuid(&auth.selected_profile.id)?,
+ name: auth.selected_profile.name,
+ ..MinecraftProfile::default()
+ };
+
+ Ok(true)
+ }
+
+ /// Whether Ely.by still accepts this account's access token.
+ async fn ely_token_is_valid(&self) -> bool {
+ let response = INSECURE_REQWEST_CLIENT
+ .post(format!("{ELY_AUTHSERVER}/auth/validate"))
+ .json(&json!({ "accessToken": &self.access_token }))
+ .send()
+ .await;
+
+ match response {
+ Ok(response) => response.status().is_success(),
+ // Unreachable is not invalid; see `refresh_ely`.
+ Err(_) => true,
+ }
+ }
+
/// Refreshes the authentication tokens for this user if they are expired, or
/// very close to expiration.
async fn refresh(
@@ -338,6 +528,22 @@ impl Credentials {
return Ok(());
}
+ // Ely.by issues its own tokens and renews them at its own endpoint,
+ // so Microsoft is not involved at any point below.
+ if self.is_ely() {
+ if !self.ely_token_is_valid().await && !self.refresh_ely().await? {
+ return Err(crate::ErrorKind::OtherError(
+ "Ely.by no longer accepts this account's session. Sign in again."
+ .to_string(),
+ )
+ .into());
+ }
+
+ self.expires = Utc::now() + Duration::hours(6);
+ self.upsert(exec).await?;
+ return Ok(());
+ }
+
let oauth_token = oauth_refresh(&self.refresh_token).await?;
let (pair, current_date) =
DeviceTokenPair::refresh_and_get_device_token(
@@ -411,9 +617,10 @@ impl Credentials {
&self,
cache_intent: OnlineProfileCacheIntent,
) -> Option<Arc<MinecraftProfile>> {
- // Offline accounts have no Mojang profile, so skip the request that
- // would only ever fail and fall back to the offline profile.
- if self.is_offline() {
+ // Neither offline nor Ely.by accounts have a Mojang profile, so skip
+ // the request that would only ever fail and fall back to the profile
+ // recorded locally, which already holds the right id and name.
+ if self.is_offline() || self.is_ely() {
return None;
}
diff --git a/packages/app-lib/src/util/authlib_injector.rs b/packages/app-lib/src/util/authlib_injector.rs
new file mode 100644
index 0000000..dc099f9
--- /dev/null
+++ b/packages/app-lib/src/util/authlib_injector.rs
@@ -0,0 +1,77 @@
+//! Downloads and caches the authlib-injector Java agent.
+//!
+//! Minecraft asks Mojang who a player is. An Ely.by account is not a Mojang
+//! account, so the game has to be told to ask Ely.by instead, and there is no
+//! switch for that: authlib-injector is a Java agent that rewrites the calls
+//! on the way out. Without it an Ely.by account cannot start the game at all.
+
+use std::path::PathBuf;
+
+use crate::state::DirectoryInfo;
+use crate::util::fetch::REQWEST_CLIENT;
+use crate::util::io;
+
+/// The agent's own distribution metadata.
+const LATEST_URL: &str = "https://authlib-injector.yushi.moe/artifact/latest.json";
+
+#[derive(serde::Deserialize)]
+struct LatestArtifact {
+ download_url: String,
+}
+
+/// Returns the path to the agent jar, downloading it once if it is not cached.
+///
+/// The cached copy is reused as it is. The agent is not tied to a game or
+/// launcher version, so there is nothing to keep up to date, and reusing it
+/// means an Ely.by account still launches with no network at all.
+pub async fn get_authlib_injector(
+ directories: &DirectoryInfo,
+) -> crate::Result<PathBuf> {
+ let dir = directories.caches_dir().join("authlib-injector");
+ io::create_dir_all(&dir).await?;
+
+ let jar = dir.join("authlib-injector.jar");
+ if io::metadata(&jar).await.is_ok() {
+ return Ok(jar);
+ }
+
+ tracing::info!("Downloading authlib-injector for an Ely.by launch");
+
+ let latest = REQWEST_CLIENT
+ .get(LATEST_URL)
+ .send()
+ .await
+ .map_err(|error| {
+ crate::ErrorKind::OtherError(format!(
+ "Could not reach the authlib-injector distribution: {error}"
+ ))
+ })?
+ .json::<LatestArtifact>()
+ .await
+ .map_err(|error| {
+ crate::ErrorKind::OtherError(format!(
+ "Could not read the authlib-injector metadata: {error}"
+ ))
+ })?;
+
+ let bytes = REQWEST_CLIENT
+ .get(&latest.download_url)
+ .send()
+ .await
+ .map_err(|error| {
+ crate::ErrorKind::OtherError(format!(
+ "Could not download authlib-injector: {error}"
+ ))
+ })?
+ .bytes()
+ .await
+ .map_err(|error| {
+ crate::ErrorKind::OtherError(format!(
+ "Could not read the authlib-injector download: {error}"
+ ))
+ })?;
+
+ io::write(&jar, &bytes).await?;
+
+ Ok(jar)
+}
diff --git a/packages/app-lib/src/util/mod.rs b/packages/app-lib/src/util/mod.rs
index 1962d6c..395d7f1 100644
--- a/packages/app-lib/src/util/mod.rs
+++ b/packages/app-lib/src/util/mod.rs
@@ -1,4 +1,5 @@
//! Theseus utility functions
+pub mod authlib_injector;
pub(crate) mod content_hash;
pub mod fetch;
pub mod io;