841 lines
29 KiB
Diff
841 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; the
|
|
account name and password go to authserver.ely.by and nowhere else, and
|
|
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 | 166 ++++++++++++++
|
|
.../MinecraftRequiredModal.vue | 29 ++-
|
|
apps/app-frontend/src/helpers/auth.js | 15 ++
|
|
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 +
|
|
10 files changed, 578 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..d909ec3
|
|
--- /dev/null
|
|
+++ b/apps/app-frontend/src/components/ui/ElyAccountModal.vue
|
|
@@ -0,0 +1,166 @@
|
|
+<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) {
|
|
+ error.value =
|
|
+ typeof e === 'string' ? e : ((e as Error)?.message ?? formatMessage(messages.genericError))
|
|
+ } 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. Your credentials go to Ely.by and nowhere else; the game is pointed at it with authlib-injector when the instance launches.',
|
|
+ },
|
|
+ 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/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 b482547..660f63b 100644
|
|
--- a/packages/app-lib/src/launcher/mod.rs
|
|
+++ b/packages/app-lib/src/launcher/mod.rs
|
|
@@ -1056,6 +1056,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 d97d233..25c6dfb 100644
|
|
--- a/packages/app-lib/src/state/minecraft_auth.rs
|
|
+++ b/packages/app-lib/src/state/minecraft_auth.rs
|
|
@@ -241,6 +241,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.
|
|
@@ -321,6 +382,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(
|
|
@@ -340,6 +530,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(
|
|
@@ -413,9 +619,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 7656b4a..d0e4d5d 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 mod fetch;
|
|
pub mod io;
|
|
pub mod jre;
|