summaryrefslogtreecommitdiff
path: root/meshmc/launcher/minecraft/auth
diff options
context:
space:
mode:
Diffstat (limited to 'meshmc/launcher/minecraft/auth')
-rw-r--r--meshmc/launcher/minecraft/auth/AccountData.cpp362
-rw-r--r--meshmc/launcher/minecraft/auth/AccountData.h103
-rw-r--r--meshmc/launcher/minecraft/auth/AccountList.cpp722
-rw-r--r--meshmc/launcher/minecraft/auth/AccountList.h187
-rw-r--r--meshmc/launcher/minecraft/auth/AccountTask.cpp129
-rw-r--r--meshmc/launcher/minecraft/auth/AccountTask.h101
-rw-r--r--meshmc/launcher/minecraft/auth/AuthRequest.cpp162
-rw-r--r--meshmc/launcher/minecraft/auth/AuthRequest.h93
-rw-r--r--meshmc/launcher/minecraft/auth/AuthSession.cpp57
-rw-r--r--meshmc/launcher/minecraft/auth/AuthSession.h71
-rw-r--r--meshmc/launcher/minecraft/auth/AuthStep.cpp26
-rw-r--r--meshmc/launcher/minecraft/auth/AuthStep.h54
-rw-r--r--meshmc/launcher/minecraft/auth/MinecraftAccount.cpp257
-rw-r--r--meshmc/launcher/minecraft/auth/MinecraftAccount.h198
-rw-r--r--meshmc/launcher/minecraft/auth/Parsers.cpp366
-rw-r--r--meshmc/launcher/minecraft/auth/Parsers.h42
-rw-r--r--meshmc/launcher/minecraft/auth/flows/AuthFlow.cpp93
-rw-r--r--meshmc/launcher/minecraft/auth/flows/AuthFlow.h64
-rw-r--r--meshmc/launcher/minecraft/auth/flows/MSA.cpp65
-rw-r--r--meshmc/launcher/minecraft/auth/flows/MSA.h37
-rw-r--r--meshmc/launcher/minecraft/auth/steps/EntitlementsStep.cpp80
-rw-r--r--meshmc/launcher/minecraft/auth/steps/EntitlementsStep.h47
-rw-r--r--meshmc/launcher/minecraft/auth/steps/GetSkinStep.cpp64
-rw-r--r--meshmc/launcher/minecraft/auth/steps/GetSkinStep.h44
-rw-r--r--meshmc/launcher/minecraft/auth/steps/MSAStep.cpp161
-rw-r--r--meshmc/launcher/minecraft/auth/steps/MSAStep.h55
-rw-r--r--meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.cpp98
-rw-r--r--meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.h44
-rw-r--r--meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp101
-rw-r--r--meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.h44
-rw-r--r--meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp191
-rw-r--r--meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.h55
-rw-r--r--meshmc/launcher/minecraft/auth/steps/XboxProfileStep.cpp96
-rw-r--r--meshmc/launcher/minecraft/auth/steps/XboxProfileStep.h44
-rw-r--r--meshmc/launcher/minecraft/auth/steps/XboxUserStep.cpp93
-rw-r--r--meshmc/launcher/minecraft/auth/steps/XboxUserStep.h44
36 files changed, 4450 insertions, 0 deletions
diff --git a/meshmc/launcher/minecraft/auth/AccountData.cpp b/meshmc/launcher/minecraft/auth/AccountData.cpp
new file mode 100644
index 0000000000..74d67c0478
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/AccountData.cpp
@@ -0,0 +1,362 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "AccountData.h"
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonArray>
+#include <QDebug>
+#include <QUuid>
+#include <QRegularExpression>
+
+namespace
+{
+ void tokenToJSONV3(QJsonObject& parent, Katabasis::Token t,
+ const char* tokenName)
+ {
+ if (!t.persistent) {
+ return;
+ }
+ QJsonObject out;
+ if (t.issueInstant.isValid()) {
+ out["iat"] = QJsonValue(t.issueInstant.toMSecsSinceEpoch() / 1000);
+ }
+
+ if (t.notAfter.isValid()) {
+ out["exp"] = QJsonValue(t.notAfter.toMSecsSinceEpoch() / 1000);
+ }
+
+ bool save = false;
+ if (!t.token.isEmpty()) {
+ out["token"] = QJsonValue(t.token);
+ save = true;
+ }
+ if (!t.refresh_token.isEmpty()) {
+ out["refresh_token"] = QJsonValue(t.refresh_token);
+ save = true;
+ }
+ if (t.extra.size()) {
+ out["extra"] = QJsonObject::fromVariantMap(t.extra);
+ save = true;
+ }
+ if (save) {
+ parent[tokenName] = out;
+ }
+ }
+
+ Katabasis::Token tokenFromJSONV3(const QJsonObject& parent,
+ const char* tokenName)
+ {
+ Katabasis::Token out;
+ auto tokenObject = parent.value(tokenName).toObject();
+ if (tokenObject.isEmpty()) {
+ return out;
+ }
+ auto issueInstant = tokenObject.value("iat");
+ if (issueInstant.isDouble()) {
+ out.issueInstant = QDateTime::fromMSecsSinceEpoch(
+ ((int64_t)issueInstant.toDouble()) * 1000);
+ }
+
+ auto notAfter = tokenObject.value("exp");
+ if (notAfter.isDouble()) {
+ out.notAfter = QDateTime::fromMSecsSinceEpoch(
+ ((int64_t)notAfter.toDouble()) * 1000);
+ }
+
+ auto token = tokenObject.value("token");
+ if (token.isString()) {
+ out.token = token.toString();
+ out.validity = Katabasis::Validity::Assumed;
+ }
+
+ auto refresh_token = tokenObject.value("refresh_token");
+ if (refresh_token.isString()) {
+ out.refresh_token = refresh_token.toString();
+ }
+
+ auto extra = tokenObject.value("extra");
+ if (extra.isObject()) {
+ out.extra = extra.toObject().toVariantMap();
+ }
+ return out;
+ }
+
+ void profileToJSONV3(QJsonObject& parent, MinecraftProfile p,
+ const char* tokenName)
+ {
+ if (p.id.isEmpty()) {
+ return;
+ }
+ QJsonObject out;
+ out["id"] = QJsonValue(p.id);
+ out["name"] = QJsonValue(p.name);
+ if (!p.currentCape.isEmpty()) {
+ out["cape"] = p.currentCape;
+ }
+
+ {
+ QJsonObject skinObj;
+ skinObj["id"] = p.skin.id;
+ skinObj["url"] = p.skin.url;
+ skinObj["variant"] = p.skin.variant;
+ if (p.skin.data.size()) {
+ skinObj["data"] = QString::fromLatin1(p.skin.data.toBase64());
+ }
+ out["skin"] = skinObj;
+ }
+
+ QJsonArray capesArray;
+ for (auto& cape : p.capes) {
+ QJsonObject capeObj;
+ capeObj["id"] = cape.id;
+ capeObj["url"] = cape.url;
+ capeObj["alias"] = cape.alias;
+ if (cape.data.size()) {
+ capeObj["data"] = QString::fromLatin1(cape.data.toBase64());
+ }
+ capesArray.push_back(capeObj);
+ }
+ out["capes"] = capesArray;
+ parent[tokenName] = out;
+ }
+
+ MinecraftProfile profileFromJSONV3(const QJsonObject& parent,
+ const char* tokenName)
+ {
+ MinecraftProfile out;
+ auto tokenObject = parent.value(tokenName).toObject();
+ if (tokenObject.isEmpty()) {
+ return out;
+ }
+ {
+ auto idV = tokenObject.value("id");
+ auto nameV = tokenObject.value("name");
+ if (!idV.isString() || !nameV.isString()) {
+ qWarning() << "mandatory profile attributes are missing or of "
+ "unexpected type";
+ return MinecraftProfile();
+ }
+ out.name = nameV.toString();
+ out.id = idV.toString();
+ }
+
+ {
+ auto skinV = tokenObject.value("skin");
+ if (!skinV.isObject()) {
+ qWarning() << "skin is missing";
+ return MinecraftProfile();
+ }
+ auto skinObj = skinV.toObject();
+ auto idV = skinObj.value("id");
+ auto urlV = skinObj.value("url");
+ auto variantV = skinObj.value("variant");
+ if (!idV.isString() || !urlV.isString() || !variantV.isString()) {
+ qWarning() << "mandatory skin attributes are missing or of "
+ "unexpected type";
+ return MinecraftProfile();
+ }
+ out.skin.id = idV.toString();
+ out.skin.url = urlV.toString();
+ out.skin.variant = variantV.toString();
+
+ // data for skin is optional
+ auto dataV = skinObj.value("data");
+ if (dataV.isString()) {
+ // TODO: validate base64
+ out.skin.data =
+ QByteArray::fromBase64(dataV.toString().toLatin1());
+ } else if (!dataV.isUndefined()) {
+ qWarning() << "skin data is something unexpected";
+ return MinecraftProfile();
+ }
+ }
+
+ {
+ auto capesV = tokenObject.value("capes");
+ if (!capesV.isArray()) {
+ qWarning() << "capes is not an array!";
+ return MinecraftProfile();
+ }
+ auto capesArray = capesV.toArray();
+ for (auto capeV : capesArray) {
+ if (!capeV.isObject()) {
+ qWarning() << "cape is not an object!";
+ return MinecraftProfile();
+ }
+ auto capeObj = capeV.toObject();
+ auto idV = capeObj.value("id");
+ auto urlV = capeObj.value("url");
+ auto aliasV = capeObj.value("alias");
+ if (!idV.isString() || !urlV.isString() || !aliasV.isString()) {
+ qWarning() << "mandatory skin attributes are missing or of "
+ "unexpected type";
+ return MinecraftProfile();
+ }
+ Cape cape;
+ cape.id = idV.toString();
+ cape.url = urlV.toString();
+ cape.alias = aliasV.toString();
+
+ // data for cape is optional.
+ auto dataV = capeObj.value("data");
+ if (dataV.isString()) {
+ // TODO: validate base64
+ cape.data =
+ QByteArray::fromBase64(dataV.toString().toLatin1());
+ } else if (!dataV.isUndefined()) {
+ qWarning() << "cape data is something unexpected";
+ return MinecraftProfile();
+ }
+ out.capes[cape.id] = cape;
+ }
+ }
+ // current cape
+ {
+ auto capeV = tokenObject.value("cape");
+ if (capeV.isString()) {
+ auto currentCape = capeV.toString();
+ if (out.capes.contains(currentCape)) {
+ out.currentCape = currentCape;
+ }
+ }
+ }
+ out.validity = Katabasis::Validity::Assumed;
+ return out;
+ }
+
+ void entitlementToJSONV3(QJsonObject& parent, MinecraftEntitlement p)
+ {
+ if (p.validity == Katabasis::Validity::None) {
+ return;
+ }
+ QJsonObject out;
+ out["ownsMinecraft"] = QJsonValue(p.ownsMinecraft);
+ out["canPlayMinecraft"] = QJsonValue(p.canPlayMinecraft);
+ parent["entitlement"] = out;
+ }
+
+ bool entitlementFromJSONV3(const QJsonObject& parent,
+ MinecraftEntitlement& out)
+ {
+ auto entitlementObject = parent.value("entitlement").toObject();
+ if (entitlementObject.isEmpty()) {
+ return false;
+ }
+ {
+ auto ownsMinecraftV = entitlementObject.value("ownsMinecraft");
+ auto canPlayMinecraftV =
+ entitlementObject.value("canPlayMinecraft");
+ if (!ownsMinecraftV.isBool() || !canPlayMinecraftV.isBool()) {
+ qWarning()
+ << "mandatory attributes are missing or of unexpected type";
+ return false;
+ }
+ out.canPlayMinecraft = canPlayMinecraftV.toBool(false);
+ out.ownsMinecraft = ownsMinecraftV.toBool(false);
+ out.validity = Katabasis::Validity::Assumed;
+ }
+ return true;
+ }
+
+} // namespace
+
+bool AccountData::resumeStateFromV3(QJsonObject data)
+{
+ auto typeV = data.value("type");
+ if (!typeV.isString()) {
+ qWarning() << "Failed to parse account data: type is missing.";
+ return false;
+ }
+ auto typeS = typeV.toString();
+ if (typeS == "MSA") {
+ type = AccountType::MSA;
+ } else {
+ qWarning() << "Failed to parse account data: type is not recognized "
+ "(only MSA is supported).";
+ return false;
+ }
+
+ msaToken = tokenFromJSONV3(data, "msa");
+ userToken = tokenFromJSONV3(data, "utoken");
+ xboxApiToken = tokenFromJSONV3(data, "xrp-main");
+ mojangservicesToken = tokenFromJSONV3(data, "xrp-mc");
+
+ yggdrasilToken = tokenFromJSONV3(data, "ygg");
+ minecraftProfile = profileFromJSONV3(data, "profile");
+ if (!entitlementFromJSONV3(data, minecraftEntitlement)) {
+ if (minecraftProfile.validity != Katabasis::Validity::None) {
+ minecraftEntitlement.canPlayMinecraft = true;
+ minecraftEntitlement.ownsMinecraft = true;
+ minecraftEntitlement.validity = Katabasis::Validity::Assumed;
+ }
+ }
+
+ validity_ = minecraftProfile.validity;
+ return true;
+}
+
+QJsonObject AccountData::saveState() const
+{
+ QJsonObject output;
+ output["type"] = "MSA";
+ tokenToJSONV3(output, msaToken, "msa");
+ tokenToJSONV3(output, userToken, "utoken");
+ tokenToJSONV3(output, xboxApiToken, "xrp-main");
+ tokenToJSONV3(output, mojangservicesToken, "xrp-mc");
+
+ tokenToJSONV3(output, yggdrasilToken, "ygg");
+ profileToJSONV3(output, minecraftProfile, "profile");
+ entitlementToJSONV3(output, minecraftEntitlement);
+ return output;
+}
+
+QString AccountData::accessToken() const
+{
+ return yggdrasilToken.token;
+}
+
+QString AccountData::profileId() const
+{
+ return minecraftProfile.id;
+}
+
+QString AccountData::profileName() const
+{
+ if (minecraftProfile.name.size() == 0) {
+ return QObject::tr("No profile (%1)").arg(accountDisplayString());
+ } else {
+ return minecraftProfile.name;
+ }
+}
+
+QString AccountData::accountDisplayString() const
+{
+ if (xboxApiToken.extra.contains("gtg")) {
+ return xboxApiToken.extra["gtg"].toString();
+ }
+ return "Xbox profile missing";
+}
+
+QString AccountData::lastError() const
+{
+ return errorString;
+}
diff --git a/meshmc/launcher/minecraft/auth/AccountData.h b/meshmc/launcher/minecraft/auth/AccountData.h
new file mode 100644
index 0000000000..9e791c568e
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/AccountData.h
@@ -0,0 +1,103 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QString>
+#include <QByteArray>
+#include <QVector>
+#include <katabasis/Bits.h>
+#include <QJsonObject>
+
+struct Skin {
+ QString id;
+ QString url;
+ QString variant;
+
+ QByteArray data;
+};
+
+struct Cape {
+ QString id;
+ QString url;
+ QString alias;
+
+ QByteArray data;
+};
+
+struct MinecraftEntitlement {
+ bool ownsMinecraft = false;
+ bool canPlayMinecraft = false;
+ Katabasis::Validity validity = Katabasis::Validity::None;
+};
+
+struct MinecraftProfile {
+ QString id;
+ QString name;
+ Skin skin;
+ QString currentCape;
+ QMap<QString, Cape> capes;
+ Katabasis::Validity validity = Katabasis::Validity::None;
+};
+
+enum class AccountType { MSA };
+
+enum class AccountState {
+ Unchecked,
+ Offline,
+ Working,
+ Online,
+ Errored,
+ Expired,
+ Gone
+};
+
+struct AccountData {
+ QJsonObject saveState() const;
+ bool resumeStateFromV3(QJsonObject data);
+
+ //! Gamertag for MSA
+ QString accountDisplayString() const;
+
+ //! Yggdrasil access token, as passed to the game.
+ QString accessToken() const;
+
+ QString profileId() const;
+ QString profileName() const;
+
+ QString lastError() const;
+
+ AccountType type = AccountType::MSA;
+
+ Katabasis::Token msaToken;
+ Katabasis::Token userToken;
+ Katabasis::Token xboxApiToken;
+ Katabasis::Token mojangservicesToken;
+
+ Katabasis::Token yggdrasilToken;
+ MinecraftProfile minecraftProfile;
+ MinecraftEntitlement minecraftEntitlement;
+ Katabasis::Validity validity_ = Katabasis::Validity::None;
+
+ // runtime only information (not saved with the account)
+ QString internalId;
+ QString errorString;
+ AccountState accountState = AccountState::Unchecked;
+};
diff --git a/meshmc/launcher/minecraft/auth/AccountList.cpp b/meshmc/launcher/minecraft/auth/AccountList.cpp
new file mode 100644
index 0000000000..f12a00815c
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/AccountList.cpp
@@ -0,0 +1,722 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "AccountList.h"
+#include "AccountData.h"
+#include "AccountTask.h"
+
+#include <QIODevice>
+#include <QFile>
+#include <QTextStream>
+#include <QJsonDocument>
+#include <QJsonArray>
+#include <QJsonObject>
+#include <QJsonParseError>
+#include <QDir>
+#include <QTimer>
+
+#include <QDebug>
+
+#include <FileSystem.h>
+#include <QSaveFile>
+
+#include <chrono>
+
+enum AccountListVersion { MojangOnly = 2, MojangMSA = 3 };
+
+AccountList::AccountList(QObject* parent) : QAbstractListModel(parent)
+{
+ m_refreshTimer = new QTimer(this);
+ m_refreshTimer->setSingleShot(true);
+ connect(m_refreshTimer, &QTimer::timeout, this, &AccountList::fillQueue);
+ m_nextTimer = new QTimer(this);
+ m_nextTimer->setSingleShot(true);
+ connect(m_nextTimer, &QTimer::timeout, this, &AccountList::tryNext);
+}
+
+AccountList::~AccountList() noexcept {}
+
+int AccountList::findAccountByProfileId(const QString& profileId) const
+{
+ for (int i = 0; i < count(); i++) {
+ MinecraftAccountPtr account = at(i);
+ if (account->profileId() == profileId) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+MinecraftAccountPtr
+AccountList::getAccountByProfileName(const QString& profileName) const
+{
+ for (int i = 0; i < count(); i++) {
+ MinecraftAccountPtr account = at(i);
+ if (account->profileName() == profileName) {
+ return account;
+ }
+ }
+ return nullptr;
+}
+
+const MinecraftAccountPtr AccountList::at(int i) const
+{
+ return MinecraftAccountPtr(m_accounts.at(i));
+}
+
+QStringList AccountList::profileNames() const
+{
+ QStringList out;
+ for (auto& account : m_accounts) {
+ auto profileName = account->profileName();
+ if (profileName.isEmpty()) {
+ continue;
+ }
+ out.append(profileName);
+ }
+ return out;
+}
+
+void AccountList::addAccount(const MinecraftAccountPtr account)
+{
+ // NOTE: Do not allow adding something that's already there
+ if (m_accounts.contains(account)) {
+ return;
+ }
+
+ // hook up notifications for changes in the account
+ connect(account.get(), &MinecraftAccount::changed, this,
+ &AccountList::accountChanged);
+ connect(account.get(), &MinecraftAccount::activityChanged, this,
+ &AccountList::accountActivityChanged);
+
+ // override/replace existing account with the same profileId
+ auto profileId = account->profileId();
+ if (profileId.size()) {
+ auto existingAccount = findAccountByProfileId(profileId);
+ if (existingAccount != -1) {
+ MinecraftAccountPtr existingAccountPtr =
+ m_accounts[existingAccount];
+ m_accounts[existingAccount] = account;
+ if (m_defaultAccount == existingAccountPtr) {
+ m_defaultAccount = account;
+ }
+ // disconnect notifications for changes in the account being
+ // replaced
+ existingAccountPtr->disconnect(this);
+ emit dataChanged(
+ index(existingAccount),
+ index(existingAccount, columnCount(QModelIndex()) - 1));
+ onListChanged();
+ return;
+ }
+ }
+
+ // if we don't have this profileId yet, add the account to the end
+ int row = m_accounts.count();
+ beginInsertRows(QModelIndex(), row, row);
+ m_accounts.append(account);
+ endInsertRows();
+ onListChanged();
+}
+
+void AccountList::removeAccount(QModelIndex index)
+{
+ int row = index.row();
+ if (index.isValid() && row >= 0 && row < m_accounts.size()) {
+ auto& account = m_accounts[row];
+ if (account == m_defaultAccount) {
+ m_defaultAccount = nullptr;
+ onDefaultAccountChanged();
+ }
+ account->disconnect(this);
+
+ beginRemoveRows(QModelIndex(), row, row);
+ m_accounts.removeAt(index.row());
+ endRemoveRows();
+ onListChanged();
+ }
+}
+
+MinecraftAccountPtr AccountList::defaultAccount() const
+{
+ return m_defaultAccount;
+}
+
+void AccountList::setDefaultAccount(MinecraftAccountPtr newAccount)
+{
+ if (!newAccount && m_defaultAccount) {
+ int idx = 0;
+ auto previousDefaultAccount = m_defaultAccount;
+ m_defaultAccount = nullptr;
+ for (MinecraftAccountPtr account : m_accounts) {
+ if (account == previousDefaultAccount) {
+ emit dataChanged(index(idx),
+ index(idx, columnCount(QModelIndex()) - 1));
+ }
+ idx++;
+ }
+ onDefaultAccountChanged();
+ } else {
+ auto currentDefaultAccount = m_defaultAccount;
+ int currentDefaultAccountIdx = -1;
+ auto newDefaultAccount = m_defaultAccount;
+ int newDefaultAccountIdx = -1;
+ int idx = 0;
+ for (MinecraftAccountPtr account : m_accounts) {
+ if (account == newAccount) {
+ newDefaultAccount = account;
+ newDefaultAccountIdx = idx;
+ }
+ if (currentDefaultAccount == account) {
+ currentDefaultAccountIdx = idx;
+ }
+ idx++;
+ }
+ if (currentDefaultAccount != newDefaultAccount) {
+ emit dataChanged(index(currentDefaultAccountIdx),
+ index(currentDefaultAccountIdx,
+ columnCount(QModelIndex()) - 1));
+ emit dataChanged(
+ index(newDefaultAccountIdx),
+ index(newDefaultAccountIdx, columnCount(QModelIndex()) - 1));
+ m_defaultAccount = newDefaultAccount;
+ onDefaultAccountChanged();
+ }
+ }
+}
+
+void AccountList::accountChanged()
+{
+ // the list changed. there is no doubt.
+ onListChanged();
+}
+
+void AccountList::accountActivityChanged(bool active)
+{
+ MinecraftAccount* account = qobject_cast<MinecraftAccount*>(sender());
+ bool found = false;
+ for (int i = 0; i < count(); i++) {
+ if (at(i).get() == account) {
+ emit dataChanged(index(i),
+ index(i, columnCount(QModelIndex()) - 1));
+ found = true;
+ break;
+ }
+ }
+ if (found) {
+ emit listActivityChanged();
+ if (active) {
+ beginActivity();
+ } else {
+ endActivity();
+ }
+ }
+}
+
+void AccountList::onListChanged()
+{
+ if (m_autosave)
+ // TODO: Alert the user if this fails.
+ saveList();
+
+ emit listChanged();
+}
+
+void AccountList::onDefaultAccountChanged()
+{
+ if (m_autosave)
+ saveList();
+
+ emit defaultAccountChanged();
+}
+
+int AccountList::count() const
+{
+ return m_accounts.count();
+}
+
+QVariant AccountList::data(const QModelIndex& index, int role) const
+{
+ if (!index.isValid())
+ return QVariant();
+
+ if (index.row() > count())
+ return QVariant();
+
+ MinecraftAccountPtr account = at(index.row());
+
+ switch (role) {
+ case Qt::DisplayRole:
+ switch (index.column()) {
+ case NameColumn:
+ return account->accountDisplayString();
+
+ case TypeColumn: {
+ auto typeStr = account->typeString();
+ typeStr[0] = typeStr[0].toUpper();
+ return typeStr;
+ }
+
+ case StatusColumn: {
+ switch (account->accountState()) {
+ case AccountState::Unchecked: {
+ return tr("Unchecked", "Account status");
+ }
+ case AccountState::Offline: {
+ return tr("Offline", "Account status");
+ }
+ case AccountState::Online: {
+ return tr("Online", "Account status");
+ }
+ case AccountState::Working: {
+ return tr("Working", "Account status");
+ }
+ case AccountState::Errored: {
+ return tr("Errored", "Account status");
+ }
+ case AccountState::Expired: {
+ return tr("Expired", "Account status");
+ }
+ case AccountState::Gone: {
+ return tr("Gone", "Account status");
+ }
+ }
+ }
+
+ case ProfileNameColumn: {
+ return account->profileName();
+ }
+
+ default:
+ return QVariant();
+ }
+
+ case Qt::ToolTipRole:
+ return account->accountDisplayString();
+
+ case PointerRole:
+ return QVariant::fromValue(account);
+
+ case Qt::CheckStateRole:
+ switch (index.column()) {
+ case NameColumn:
+ return account == m_defaultAccount ? Qt::Checked
+ : Qt::Unchecked;
+ }
+
+ default:
+ return QVariant();
+ }
+}
+
+QVariant AccountList::headerData(int section, Qt::Orientation orientation,
+ int role) const
+{
+ switch (role) {
+ case Qt::DisplayRole:
+ switch (section) {
+ case NameColumn:
+ return tr("Account");
+ case TypeColumn:
+ return tr("Type");
+ case StatusColumn:
+ return tr("Status");
+ case ProfileNameColumn:
+ return tr("Profile");
+ default:
+ return QVariant();
+ }
+
+ case Qt::ToolTipRole:
+ switch (section) {
+ case NameColumn:
+ return tr("User name of the account.");
+ case TypeColumn:
+ return tr("Type of the account.");
+ case StatusColumn:
+ return tr("Current status of the account.");
+ case ProfileNameColumn:
+ return tr("Name of the Minecraft profile associated with "
+ "the account.");
+ default:
+ return QVariant();
+ }
+
+ default:
+ return QVariant();
+ }
+}
+
+int AccountList::rowCount(const QModelIndex&) const
+{
+ // Return count
+ return count();
+}
+
+int AccountList::columnCount(const QModelIndex&) const
+{
+ return NUM_COLUMNS;
+}
+
+Qt::ItemFlags AccountList::flags(const QModelIndex& index) const
+{
+ if (index.row() < 0 || index.row() >= rowCount(index) || !index.isValid()) {
+ return Qt::NoItemFlags;
+ }
+
+ return Qt::ItemIsUserCheckable | Qt::ItemIsEnabled | Qt::ItemIsSelectable;
+}
+
+bool AccountList::setData(const QModelIndex& idx, const QVariant& value,
+ int role)
+{
+ if (idx.row() < 0 || idx.row() >= rowCount(idx) || !idx.isValid()) {
+ return false;
+ }
+
+ if (role == Qt::CheckStateRole) {
+ if (value == Qt::Checked) {
+ MinecraftAccountPtr account = at(idx.row());
+ setDefaultAccount(account);
+ }
+ }
+
+ emit dataChanged(idx, index(idx.row(), columnCount(QModelIndex()) - 1));
+ return true;
+}
+
+bool AccountList::loadList()
+{
+ if (m_listFilePath.isEmpty()) {
+ qCritical() << "Can't load account list. No file path given and no "
+ "default set.";
+ return false;
+ }
+
+ QFile file(m_listFilePath);
+
+ // Try to open the file and fail if we can't.
+ // TODO: We should probably report this error to the user.
+ if (!file.open(QIODevice::ReadOnly)) {
+ qCritical() << QString("Failed to read the account list file (%1).")
+ .arg(m_listFilePath)
+ .toUtf8();
+ return false;
+ }
+
+ // Read the file and close it.
+ QByteArray jsonData = file.readAll();
+ file.close();
+
+ QJsonParseError parseError;
+ QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError);
+
+ // Fail if the JSON is invalid.
+ if (parseError.error != QJsonParseError::NoError) {
+ qCritical() << QString(
+ "Failed to parse account list file: %1 at offset %2")
+ .arg(parseError.errorString(),
+ QString::number(parseError.offset))
+ .toUtf8();
+ return false;
+ }
+
+ // Make sure the root is an object.
+ if (!jsonDoc.isObject()) {
+ qCritical() << "Invalid account list JSON: Root should be an array.";
+ return false;
+ }
+
+ QJsonObject root = jsonDoc.object();
+
+ // Make sure the format version matches.
+ auto listVersion = root.value("formatVersion").toVariant().toInt();
+ switch (listVersion) {
+ case AccountListVersion::MojangMSA: {
+ return loadV3(root);
+ } break;
+ default: {
+ QString newName = "accounts-old.json";
+ qWarning() << "Unknown format version when loading account list. "
+ "Existing one will be renamed to"
+ << newName;
+ // Attempt to rename the old version.
+ file.rename(newName);
+ return false;
+ }
+ }
+}
+
+bool AccountList::loadV3(QJsonObject& root)
+{
+ beginResetModel();
+ QJsonArray accounts = root.value("accounts").toArray();
+ for (QJsonValue accountVal : accounts) {
+ QJsonObject accountObj = accountVal.toObject();
+ MinecraftAccountPtr account =
+ MinecraftAccount::loadFromJsonV3(accountObj);
+ if (account.get() != nullptr) {
+ auto profileId = account->profileId();
+ if (profileId.size()) {
+ if (findAccountByProfileId(profileId) != -1) {
+ continue;
+ }
+ }
+ connect(account.get(), &MinecraftAccount::changed, this,
+ &AccountList::accountChanged);
+ connect(account.get(), &MinecraftAccount::activityChanged, this,
+ &AccountList::accountActivityChanged);
+ m_accounts.append(account);
+ if (accountObj.value("active").toBool(false)) {
+ m_defaultAccount = account;
+ }
+ } else {
+ qWarning() << "Failed to load an account.";
+ }
+ }
+ endResetModel();
+ return true;
+}
+
+bool AccountList::saveList()
+{
+ if (m_listFilePath.isEmpty()) {
+ qCritical() << "Can't save account list. No file path given and no "
+ "default set.";
+ return false;
+ }
+
+ // make sure the parent folder exists
+ if (!FS::ensureFilePathExists(m_listFilePath))
+ return false;
+
+ // make sure the file wasn't overwritten with a folder before (fixes a bug)
+ QFileInfo finfo(m_listFilePath);
+ if (finfo.isDir()) {
+ QDir badDir(m_listFilePath);
+ badDir.removeRecursively();
+ }
+
+ qDebug() << "Writing account list to" << m_listFilePath;
+
+ qDebug() << "Building JSON data structure.";
+ // Build the JSON document to write to the list file.
+ QJsonObject root;
+
+ root.insert("formatVersion", AccountListVersion::MojangMSA);
+
+ // Build a list of accounts.
+ qDebug() << "Building account array.";
+ QJsonArray accounts;
+ for (MinecraftAccountPtr account : m_accounts) {
+ QJsonObject accountObj = account->saveToJson();
+ if (m_defaultAccount == account) {
+ accountObj["active"] = true;
+ }
+ accounts.append(accountObj);
+ }
+
+ // Insert the account list into the root object.
+ root.insert("accounts", accounts);
+
+ // Create a JSON document object to convert our JSON to bytes.
+ QJsonDocument doc(root);
+
+ // Now that we're done building the JSON object, we can write it to the
+ // file.
+ qDebug() << "Writing account list to file.";
+ QSaveFile file(m_listFilePath);
+
+ // Try to open the file and fail if we can't.
+ // TODO: We should probably report this error to the user.
+ if (!file.open(QIODevice::WriteOnly)) {
+ qCritical() << QString("Failed to read the account list file (%1).")
+ .arg(m_listFilePath)
+ .toUtf8();
+ return false;
+ }
+
+ // Write the JSON to the file.
+ file.write(doc.toJson());
+ file.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ReadUser |
+ QFile::WriteUser);
+ if (file.commit()) {
+ qDebug() << "Saved account list to" << m_listFilePath;
+ return true;
+ } else {
+ qDebug() << "Failed to save accounts to" << m_listFilePath;
+ return false;
+ }
+}
+
+void AccountList::setListFilePath(QString path, bool autosave)
+{
+ m_listFilePath = path;
+ m_autosave = autosave;
+}
+
+bool AccountList::anyAccountIsValid()
+{
+ for (auto account : m_accounts) {
+ if (account->ownsMinecraft()) {
+ return true;
+ }
+ }
+ return false;
+}
+
+void AccountList::fillQueue()
+{
+
+ if (m_defaultAccount && m_defaultAccount->shouldRefresh()) {
+ auto idToRefresh = m_defaultAccount->internalId();
+ m_refreshQueue.push_back(idToRefresh);
+ qDebug() << "AccountList: Queued default account with internal ID "
+ << idToRefresh << " to refresh first";
+ }
+
+ for (int i = 0; i < count(); i++) {
+ auto account = at(i);
+ if (account == m_defaultAccount) {
+ continue;
+ }
+
+ if (account->shouldRefresh()) {
+ auto idToRefresh = account->internalId();
+ queueRefresh(idToRefresh);
+ }
+ }
+ tryNext();
+}
+
+void AccountList::requestRefresh(QString accountId)
+{
+ auto index = m_refreshQueue.indexOf(accountId);
+ if (index != -1) {
+ m_refreshQueue.removeAt(index);
+ }
+ m_refreshQueue.push_front(accountId);
+ qDebug() << "AccountList: Pushed account with internal ID " << accountId
+ << " to the front of the queue";
+ if (!isActive()) {
+ tryNext();
+ }
+}
+
+void AccountList::queueRefresh(QString accountId)
+{
+ if (m_refreshQueue.indexOf(accountId) != -1) {
+ return;
+ }
+ m_refreshQueue.push_back(accountId);
+ qDebug() << "AccountList: Queued account with internal ID " << accountId
+ << " to refresh";
+}
+
+void AccountList::tryNext()
+{
+ while (m_refreshQueue.length()) {
+ auto accountId = m_refreshQueue.front();
+ m_refreshQueue.pop_front();
+ for (int i = 0; i < count(); i++) {
+ auto account = at(i);
+ if (account->internalId() == accountId) {
+ m_currentTask = account->refresh();
+ if (m_currentTask) {
+ connect(m_currentTask.get(), &AccountTask::succeeded, this,
+ &AccountList::authSucceeded);
+ connect(m_currentTask.get(), &AccountTask::failed, this,
+ &AccountList::authFailed);
+ m_currentTask->start();
+ qDebug() << "RefreshSchedule: Processing account "
+ << account->accountDisplayString()
+ << " with internal ID " << accountId;
+ return;
+ }
+ }
+ }
+ qDebug() << "RefreshSchedule: Account with with internal ID "
+ << accountId << " not found.";
+ }
+ // if we get here, no account needed refreshing. Schedule refresh in an
+ // hour.
+ m_refreshTimer->start(1000 * 3600);
+}
+
+void AccountList::authSucceeded()
+{
+ qDebug() << "RefreshSchedule: Background account refresh succeeded";
+ m_currentTask.reset();
+ m_nextTimer->start(1000 * 20);
+}
+
+void AccountList::authFailed(QString reason)
+{
+ qDebug() << "RefreshSchedule: Background account refresh failed: "
+ << reason;
+ m_currentTask.reset();
+ m_nextTimer->start(1000 * 20);
+}
+
+bool AccountList::isActive() const
+{
+ return m_activityCount != 0;
+}
+
+void AccountList::beginActivity()
+{
+ bool activating = m_activityCount == 0;
+ m_activityCount++;
+ if (activating) {
+ emit activityChanged(true);
+ }
+}
+
+void AccountList::endActivity()
+{
+ if (m_activityCount == 0) {
+ qWarning() << m_name << " - Activity count would become below zero";
+ return;
+ }
+ bool deactivating = m_activityCount == 1;
+ m_activityCount--;
+ if (deactivating) {
+ emit activityChanged(false);
+ }
+}
diff --git a/meshmc/launcher/minecraft/auth/AccountList.h b/meshmc/launcher/minecraft/auth/AccountList.h
new file mode 100644
index 0000000000..2d352532a8
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/AccountList.h
@@ -0,0 +1,187 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include "MinecraftAccount.h"
+
+#include <QObject>
+#include <QVariant>
+#include <QAbstractListModel>
+#include <QSharedPointer>
+
+/*!
+ * List of available Mojang accounts.
+ * This should be loaded in the background by MeshMC on startup.
+ */
+class AccountList : public QAbstractListModel
+{
+ Q_OBJECT
+ public:
+ enum ModelRoles { PointerRole = 0x34B1CB48 };
+
+ enum VListColumns {
+ // TODO: Add icon column.
+ NameColumn = 0,
+ ProfileNameColumn,
+ TypeColumn,
+ StatusColumn,
+
+ NUM_COLUMNS
+ };
+
+ explicit AccountList(QObject* parent = 0);
+ virtual ~AccountList() noexcept;
+
+ const MinecraftAccountPtr at(int i) const;
+ int count() const;
+
+ //////// List Model Functions ////////
+ QVariant data(const QModelIndex& index, int role) const override;
+ virtual QVariant headerData(int section, Qt::Orientation orientation,
+ int role) const override;
+ virtual int rowCount(const QModelIndex& parent) const override;
+ virtual int columnCount(const QModelIndex& parent) const override;
+ virtual Qt::ItemFlags flags(const QModelIndex& index) const override;
+ virtual bool setData(const QModelIndex& index, const QVariant& value,
+ int role) override;
+
+ void addAccount(const MinecraftAccountPtr account);
+ void removeAccount(QModelIndex index);
+ int findAccountByProfileId(const QString& profileId) const;
+ MinecraftAccountPtr
+ getAccountByProfileName(const QString& profileName) const;
+ QStringList profileNames() const;
+
+ // requesting a refresh pushes it to the front of the queue
+ void requestRefresh(QString accountId);
+ // queuing a refresh will let it go to the back of the queue (unless it's
+ // somewhere inside the queue already)
+ void queueRefresh(QString accountId);
+
+ /*!
+ * Sets the path to load/save the list file from/to.
+ * If autosave is true, this list will automatically save to the given path
+ * whenever it changes. THIS FUNCTION DOES NOT LOAD THE LIST. If you set
+ * autosave, be sure to call loadList() immediately after calling this
+ * function to ensure an autosaved change doesn't overwrite the list you
+ * intended to load.
+ */
+ void setListFilePath(QString path, bool autosave = false);
+
+ bool loadList();
+ bool loadV3(QJsonObject& root);
+ bool saveList();
+
+ MinecraftAccountPtr defaultAccount() const;
+ void setDefaultAccount(MinecraftAccountPtr profileId);
+ bool anyAccountIsValid();
+
+ bool isActive() const;
+
+ protected:
+ void beginActivity();
+ void endActivity();
+
+ private:
+ const char* m_name;
+ uint32_t m_activityCount = 0;
+ signals:
+ void listChanged();
+ void listActivityChanged();
+ void defaultAccountChanged();
+ void activityChanged(bool active);
+
+ public slots:
+ /**
+ * This is called when one of the accounts changes and the list needs to be
+ * updated
+ */
+ void accountChanged();
+
+ /**
+ * This is called when a (refresh/login) task involving the account starts
+ * or ends
+ */
+ void accountActivityChanged(bool active);
+
+ /**
+ * This is initially to run background account refresh tasks, or on a hourly
+ * timer
+ */
+ void fillQueue();
+
+ private slots:
+ void tryNext();
+
+ void authSucceeded();
+ void authFailed(QString reason);
+
+ protected:
+ QList<QString> m_refreshQueue;
+ QTimer* m_refreshTimer;
+ QTimer* m_nextTimer;
+ shared_qobject_ptr<AccountTask> m_currentTask;
+
+ /*!
+ * Called whenever the list changes.
+ * This emits the listChanged() signal and autosaves the list (if autosave
+ * is enabled).
+ */
+ void onListChanged();
+
+ /*!
+ * Called whenever the active account changes.
+ * Emits the defaultAccountChanged() signal and autosaves the list if
+ * enabled.
+ */
+ void onDefaultAccountChanged();
+
+ QList<MinecraftAccountPtr> m_accounts;
+
+ MinecraftAccountPtr m_defaultAccount;
+
+ //! Path to the account list file. Empty string if there isn't one.
+ QString m_listFilePath;
+
+ /*!
+ * If true, the account list will automatically save to the account list
+ * path when it changes. Ignored if m_listFilePath is blank.
+ */
+ bool m_autosave = false;
+};
diff --git a/meshmc/launcher/minecraft/auth/AccountTask.cpp b/meshmc/launcher/minecraft/auth/AccountTask.cpp
new file mode 100644
index 0000000000..ddcf1918db
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/AccountTask.cpp
@@ -0,0 +1,129 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "AccountTask.h"
+#include "MinecraftAccount.h"
+
+#include <QObject>
+#include <QString>
+#include <QJsonObject>
+#include <QJsonDocument>
+#include <QNetworkReply>
+#include <QByteArray>
+
+#include <QDebug>
+
+AccountTask::AccountTask(AccountData* data, QObject* parent)
+ : Task(parent), m_data(data)
+{
+ changeState(AccountTaskState::STATE_CREATED);
+}
+
+QString AccountTask::getStateMessage() const
+{
+ switch (m_taskState) {
+ case AccountTaskState::STATE_CREATED:
+ return "Waiting...";
+ case AccountTaskState::STATE_WORKING:
+ return tr("Sending request to auth servers...");
+ case AccountTaskState::STATE_SUCCEEDED:
+ return tr("Authentication task succeeded.");
+ case AccountTaskState::STATE_OFFLINE:
+ return tr("Failed to contact the authentication server.");
+ case AccountTaskState::STATE_FAILED_SOFT:
+ return tr("Encountered an error during authentication.");
+ case AccountTaskState::STATE_FAILED_HARD:
+ return tr("Failed to authenticate. The session has expired.");
+ case AccountTaskState::STATE_FAILED_GONE:
+ return tr("Failed to authenticate. The account no longer exists.");
+ default:
+ return tr("...");
+ }
+}
+
+bool AccountTask::changeState(AccountTaskState newState, QString reason)
+{
+ m_taskState = newState;
+ setStatus(getStateMessage());
+ switch (newState) {
+ case AccountTaskState::STATE_CREATED: {
+ m_data->errorString.clear();
+ return true;
+ }
+ case AccountTaskState::STATE_WORKING: {
+ m_data->accountState = AccountState::Working;
+ return true;
+ }
+ case AccountTaskState::STATE_SUCCEEDED: {
+ m_data->accountState = AccountState::Online;
+ emitSucceeded();
+ return false;
+ }
+ case AccountTaskState::STATE_OFFLINE: {
+ m_data->errorString = reason;
+ m_data->accountState = AccountState::Offline;
+ emitFailed(reason);
+ return false;
+ }
+ case AccountTaskState::STATE_FAILED_SOFT: {
+ m_data->errorString = reason;
+ m_data->accountState = AccountState::Errored;
+ emitFailed(reason);
+ return false;
+ }
+ case AccountTaskState::STATE_FAILED_HARD: {
+ m_data->errorString = reason;
+ m_data->accountState = AccountState::Expired;
+ emitFailed(reason);
+ return false;
+ }
+ case AccountTaskState::STATE_FAILED_GONE: {
+ m_data->errorString = reason;
+ m_data->accountState = AccountState::Gone;
+ emitFailed(reason);
+ return false;
+ }
+ default: {
+ QString error =
+ tr("Unknown account task state: %1").arg(int(newState));
+ m_data->accountState = AccountState::Errored;
+ emitFailed(error);
+ return false;
+ }
+ }
+}
diff --git a/meshmc/launcher/minecraft/auth/AccountTask.h b/meshmc/launcher/minecraft/auth/AccountTask.h
new file mode 100644
index 0000000000..184b8b4c01
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/AccountTask.h
@@ -0,0 +1,101 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <tasks/Task.h>
+
+#include <QString>
+#include <QJsonObject>
+#include <QTimer>
+#include <qsslerror.h>
+
+#include "MinecraftAccount.h"
+
+class QNetworkReply;
+
+/**
+ * Enum for describing the state of the current task.
+ * Used by the getStateMessage function to determine what the status message
+ * should be.
+ */
+enum class AccountTaskState {
+ STATE_CREATED,
+ STATE_WORKING,
+ STATE_SUCCEEDED,
+ STATE_FAILED_SOFT, //!< soft failure. authentication went through partially
+ STATE_FAILED_HARD, //!< hard failure. main tokens are invalid
+ STATE_FAILED_GONE, //!< hard failure. main tokens are invalid, and the
+ //!< account no longer exists
+ STATE_OFFLINE //!< soft failure. authentication failed in the first step in
+ //!< a 'soft' way
+};
+
+class AccountTask : public Task
+{
+ Q_OBJECT
+ public:
+ explicit AccountTask(AccountData* data, QObject* parent = 0);
+ virtual ~AccountTask() {};
+
+ AccountTaskState m_taskState = AccountTaskState::STATE_CREATED;
+
+ AccountTaskState taskState()
+ {
+ return m_taskState;
+ }
+
+ signals:
+ void authorizeWithBrowser(const QUrl& url);
+
+ protected:
+ /**
+ * Returns the state message for the given state.
+ * Used to set the status message for the task.
+ * Should be overridden by subclasses that want to change messages for a
+ * given state.
+ */
+ virtual QString getStateMessage() const;
+
+ protected slots:
+ // NOTE: true -> non-terminal state, false -> terminal state
+ bool changeState(AccountTaskState newState, QString reason = QString());
+
+ protected:
+ AccountData* m_data = nullptr;
+};
diff --git a/meshmc/launcher/minecraft/auth/AuthRequest.cpp b/meshmc/launcher/minecraft/auth/AuthRequest.cpp
new file mode 100644
index 0000000000..9edf238c57
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/AuthRequest.cpp
@@ -0,0 +1,162 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <cassert>
+
+#include <QDebug>
+#include <QTimer>
+#include <QBuffer>
+#include <QUrlQuery>
+
+#include "Application.h"
+#include "AuthRequest.h"
+#include "katabasis/Globals.h"
+
+AuthRequest::AuthRequest(QObject* parent) : QObject(parent) {}
+
+AuthRequest::~AuthRequest() {}
+
+void AuthRequest::get(const QNetworkRequest& req, int timeout /* = 60*1000*/)
+{
+ setup(req, QNetworkAccessManager::GetOperation);
+ reply_ = APPLICATION->network()->get(request_);
+ status_ = Requesting;
+ timedReplies_.add(new Katabasis::Reply(reply_, timeout));
+ connect(reply_, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this,
+ SLOT(onRequestError(QNetworkReply::NetworkError)));
+ connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()));
+ connect(reply_, &QNetworkReply::sslErrors, this, &AuthRequest::onSslErrors);
+}
+
+void AuthRequest::post(const QNetworkRequest& req, const QByteArray& data,
+ int timeout /* = 60*1000*/)
+{
+ setup(req, QNetworkAccessManager::PostOperation);
+ data_ = data;
+ status_ = Requesting;
+ reply_ = APPLICATION->network()->post(request_, data_);
+ timedReplies_.add(new Katabasis::Reply(reply_, timeout));
+ connect(reply_, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this,
+ SLOT(onRequestError(QNetworkReply::NetworkError)));
+ connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()));
+ connect(reply_, &QNetworkReply::sslErrors, this, &AuthRequest::onSslErrors);
+ connect(reply_, SIGNAL(uploadProgress(qint64, qint64)), this,
+ SLOT(onUploadProgress(qint64, qint64)));
+}
+
+void AuthRequest::onRequestFinished()
+{
+ if (status_ == Idle) {
+ return;
+ }
+ if (reply_ != qobject_cast<QNetworkReply*>(sender())) {
+ return;
+ }
+ httpStatus_ =
+ reply_->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ finish();
+}
+
+void AuthRequest::onRequestError(QNetworkReply::NetworkError error)
+{
+ qWarning() << "AuthRequest::onRequestError: Error" << (int)error;
+ if (status_ == Idle) {
+ return;
+ }
+ if (reply_ != qobject_cast<QNetworkReply*>(sender())) {
+ return;
+ }
+ errorString_ = reply_->errorString();
+ httpStatus_ =
+ reply_->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ error_ = error;
+ qWarning() << "AuthRequest::onRequestError: Error string: " << errorString_;
+ qWarning() << "AuthRequest::onRequestError: HTTP status" << httpStatus_
+ << reply_->attribute(QNetworkRequest::HttpReasonPhraseAttribute)
+ .toString();
+
+ // QTimer::singleShot(10, this, SLOT(finish()));
+}
+
+void AuthRequest::onSslErrors(QList<QSslError> errors)
+{
+ int i = 1;
+ for (auto error : errors) {
+ qCritical() << "LOGIN SSL Error #" << i << " : " << error.errorString();
+ auto cert = error.certificate();
+ qCritical() << "Certificate in question:\n" << cert.toText();
+ i++;
+ }
+}
+
+void AuthRequest::onUploadProgress(qint64 uploaded, qint64 total)
+{
+ if (status_ == Idle) {
+ qWarning() << "AuthRequest::onUploadProgress: No pending request";
+ return;
+ }
+ if (reply_ != qobject_cast<QNetworkReply*>(sender())) {
+ return;
+ }
+ // Restart timeout because request in progress
+ Katabasis::Reply* o2Reply = timedReplies_.find(reply_);
+ if (o2Reply) {
+ o2Reply->start();
+ }
+ emit uploadProgress(uploaded, total);
+}
+
+void AuthRequest::setup(const QNetworkRequest& req,
+ QNetworkAccessManager::Operation operation,
+ const QByteArray& verb)
+{
+ request_ = req;
+ operation_ = operation;
+ url_ = req.url();
+
+ QUrl url = url_;
+ request_.setUrl(url);
+
+ if (!verb.isEmpty()) {
+ request_.setRawHeader(Katabasis::HTTP_HTTP_HEADER, verb);
+ }
+
+ status_ = Requesting;
+ error_ = QNetworkReply::NoError;
+ errorString_.clear();
+ httpStatus_ = 0;
+}
+
+void AuthRequest::finish()
+{
+ QByteArray data;
+ if (status_ == Idle) {
+ qWarning() << "AuthRequest::finish: No pending request";
+ return;
+ }
+ data = reply_->readAll();
+ status_ = Idle;
+ timedReplies_.remove(reply_);
+ reply_->disconnect(this);
+ reply_->deleteLater();
+ QList<QNetworkReply::RawHeaderPair> headers = reply_->rawHeaderPairs();
+ emit finished(error_, data, headers);
+}
diff --git a/meshmc/launcher/minecraft/auth/AuthRequest.h b/meshmc/launcher/minecraft/auth/AuthRequest.h
new file mode 100644
index 0000000000..cd57fa34db
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/AuthRequest.h
@@ -0,0 +1,93 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QObject>
+#include <QNetworkRequest>
+#include <QNetworkReply>
+#include <QNetworkAccessManager>
+#include <QUrl>
+#include <QByteArray>
+
+#include "katabasis/Reply.h"
+
+/// Makes authentication requests.
+class AuthRequest : public QObject
+{
+ Q_OBJECT
+
+ public:
+ explicit AuthRequest(QObject* parent = 0);
+ ~AuthRequest();
+
+ public slots:
+ void get(const QNetworkRequest& req, int timeout = 60 * 1000);
+ void post(const QNetworkRequest& req, const QByteArray& data,
+ int timeout = 60 * 1000);
+
+ signals:
+
+ /// Emitted when a request has been completed or failed.
+ void finished(QNetworkReply::NetworkError error, QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers);
+
+ /// Emitted when an upload has progressed.
+ void uploadProgress(qint64 bytesSent, qint64 bytesTotal);
+
+ protected slots:
+
+ /// Handle request finished.
+ void onRequestFinished();
+
+ /// Handle request error.
+ void onRequestError(QNetworkReply::NetworkError error);
+
+ /// Handle ssl errors.
+ void onSslErrors(QList<QSslError> errors);
+
+ /// Finish the request, emit finished() signal.
+ void finish();
+
+ /// Handle upload progress.
+ void onUploadProgress(qint64 uploaded, qint64 total);
+
+ public:
+ QNetworkReply::NetworkError error_;
+ int httpStatus_ = 0;
+ QString errorString_;
+
+ protected:
+ void setup(const QNetworkRequest& request,
+ QNetworkAccessManager::Operation operation,
+ const QByteArray& verb = QByteArray());
+
+ enum Status { Idle, Requesting, ReRequesting };
+
+ QNetworkRequest request_;
+ QByteArray data_;
+ QNetworkReply* reply_;
+ Status status_;
+ QNetworkAccessManager::Operation operation_;
+ QUrl url_;
+ Katabasis::ReplyList timedReplies_;
+
+ QTimer* timer_;
+};
diff --git a/meshmc/launcher/minecraft/auth/AuthSession.cpp b/meshmc/launcher/minecraft/auth/AuthSession.cpp
new file mode 100644
index 0000000000..53366077ae
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/AuthSession.cpp
@@ -0,0 +1,57 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "AuthSession.h"
+#include <QJsonObject>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QStringList>
+
+QString AuthSession::serializeUserProperties()
+{
+ QJsonObject userAttrs;
+ /*
+ for (auto key : u.properties.keys())
+ {
+ auto array = QJsonArray::fromStringList(u.properties.values(key));
+ userAttrs.insert(key, array);
+ }
+ */
+ QJsonDocument value(userAttrs);
+ return value.toJson(QJsonDocument::Compact);
+}
+
+bool AuthSession::MakeOffline(QString offline_playername)
+{
+ if (status != PlayableOffline && status != PlayableOnline) {
+ return false;
+ }
+ session = "-";
+ player_name = offline_playername;
+ status = PlayableOffline;
+ return true;
+}
+
+void AuthSession::MakeDemo()
+{
+ player_name = "Player";
+ demo = true;
+}
diff --git a/meshmc/launcher/minecraft/auth/AuthSession.h b/meshmc/launcher/minecraft/auth/AuthSession.h
new file mode 100644
index 0000000000..80525bb972
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/AuthSession.h
@@ -0,0 +1,71 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QString>
+#include <QMultiMap>
+#include <memory>
+#include "QObjectPtr.h"
+
+class MinecraftAccount;
+class QNetworkAccessManager;
+
+struct AuthSession {
+ bool MakeOffline(QString offline_playername);
+ void MakeDemo();
+
+ QString serializeUserProperties();
+
+ enum Status {
+ Undetermined,
+ RequiresOAuth,
+ RequiresPassword,
+ RequiresProfileSetup,
+ PlayableOffline,
+ PlayableOnline,
+ GoneOrMigrated
+ } status = Undetermined;
+
+ // client token
+ QString client_token;
+ // account user name
+ QString username;
+ // combined session ID
+ QString session;
+ // volatile auth token
+ QString access_token;
+ // profile name
+ QString player_name;
+ // profile ID
+ QString uuid;
+ // 'legacy' or 'mojang', depending on account type
+ QString user_type;
+ // Did the auth server reply?
+ bool auth_server_online = false;
+ // Did the user request online mode?
+ bool wants_online = true;
+
+ // Is this a demo session?
+ bool demo = false;
+};
+
+typedef std::shared_ptr<AuthSession> AuthSessionPtr;
diff --git a/meshmc/launcher/minecraft/auth/AuthStep.cpp b/meshmc/launcher/minecraft/auth/AuthStep.cpp
new file mode 100644
index 0000000000..459d74d63d
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/AuthStep.cpp
@@ -0,0 +1,26 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "AuthStep.h"
+
+AuthStep::AuthStep(AccountData* data) : QObject(nullptr), m_data(data) {}
+
+AuthStep::~AuthStep() noexcept = default;
diff --git a/meshmc/launcher/minecraft/auth/AuthStep.h b/meshmc/launcher/minecraft/auth/AuthStep.h
new file mode 100644
index 0000000000..0c9c758b4e
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/AuthStep.h
@@ -0,0 +1,54 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QObject>
+#include <QList>
+#include <QNetworkReply>
+
+#include "QObjectPtr.h"
+#include "minecraft/auth/AccountData.h"
+#include "AccountTask.h"
+
+class AuthStep : public QObject
+{
+ Q_OBJECT
+
+ public:
+ using Ptr = shared_qobject_ptr<AuthStep>;
+
+ public:
+ explicit AuthStep(AccountData* data);
+ virtual ~AuthStep() noexcept;
+
+ virtual QString describe() = 0;
+
+ public slots:
+ virtual void perform() = 0;
+ virtual void rehydrate() = 0;
+
+ signals:
+ void finished(AccountTaskState resultingState, QString message);
+ void authorizeWithBrowser(const QUrl& url);
+
+ protected:
+ AccountData* m_data;
+};
diff --git a/meshmc/launcher/minecraft/auth/MinecraftAccount.cpp b/meshmc/launcher/minecraft/auth/MinecraftAccount.cpp
new file mode 100644
index 0000000000..53e77bbed0
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/MinecraftAccount.cpp
@@ -0,0 +1,257 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "MinecraftAccount.h"
+
+#include <QUuid>
+#include <QJsonObject>
+#include <QJsonArray>
+#include <QRegularExpression>
+#include <QStringList>
+#include <QJsonDocument>
+
+#include <QDebug>
+
+#include <QPainter>
+
+#include "flows/MSA.h"
+
+MinecraftAccount::MinecraftAccount(QObject* parent) : QObject(parent)
+{
+ data.internalId =
+ QUuid::createUuid().toString().remove(QRegularExpression("[{}-]"));
+}
+
+MinecraftAccountPtr MinecraftAccount::loadFromJsonV3(const QJsonObject& json)
+{
+ MinecraftAccountPtr account(new MinecraftAccount());
+ if (account->data.resumeStateFromV3(json)) {
+ return account;
+ }
+ return nullptr;
+}
+
+MinecraftAccountPtr MinecraftAccount::createBlankMSA()
+{
+ MinecraftAccountPtr account(new MinecraftAccount());
+ account->data.type = AccountType::MSA;
+ return account;
+}
+
+QJsonObject MinecraftAccount::saveToJson() const
+{
+ return data.saveState();
+}
+
+AccountState MinecraftAccount::accountState() const
+{
+ return data.accountState;
+}
+
+QPixmap MinecraftAccount::getFace() const
+{
+ QPixmap skinTexture;
+ if (!skinTexture.loadFromData(data.minecraftProfile.skin.data, "PNG")) {
+ return QPixmap();
+ }
+ QPixmap skin = QPixmap(8, 8);
+ QPainter painter(&skin);
+ painter.drawPixmap(0, 0, skinTexture.copy(8, 8, 8, 8));
+ painter.drawPixmap(0, 0, skinTexture.copy(40, 8, 8, 8));
+ return skin.scaled(64, 64, Qt::KeepAspectRatio);
+}
+
+shared_qobject_ptr<AccountTask> MinecraftAccount::loginMSA()
+{
+ Q_ASSERT(m_currentTask.get() == nullptr);
+
+ m_currentTask.reset(new MSAInteractive(&data));
+ connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded()));
+ connect(m_currentTask.get(), SIGNAL(failed(QString)),
+ SLOT(authFailed(QString)));
+ emit activityChanged(true);
+ return m_currentTask;
+}
+
+shared_qobject_ptr<AccountTask> MinecraftAccount::refresh()
+{
+ if (m_currentTask) {
+ return m_currentTask;
+ }
+
+ m_currentTask.reset(new MSASilent(&data));
+
+ connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded()));
+ connect(m_currentTask.get(), SIGNAL(failed(QString)),
+ SLOT(authFailed(QString)));
+ emit activityChanged(true);
+ return m_currentTask;
+}
+
+shared_qobject_ptr<AccountTask> MinecraftAccount::currentTask()
+{
+ return m_currentTask;
+}
+
+void MinecraftAccount::authSucceeded()
+{
+ m_currentTask.reset();
+ emit changed();
+ emit activityChanged(false);
+}
+
+void MinecraftAccount::authFailed(QString reason)
+{
+ switch (m_currentTask->taskState()) {
+ case AccountTaskState::STATE_OFFLINE:
+ case AccountTaskState::STATE_FAILED_SOFT: {
+ // NOTE: this doesn't do much. There was an error of some sort.
+ } break;
+ case AccountTaskState::STATE_FAILED_HARD: {
+ data.msaToken.token = QString();
+ data.msaToken.refresh_token = QString();
+ data.msaToken.validity = Katabasis::Validity::None;
+ data.validity_ = Katabasis::Validity::None;
+ emit changed();
+ } break;
+ case AccountTaskState::STATE_FAILED_GONE: {
+ data.validity_ = Katabasis::Validity::None;
+ emit changed();
+ } break;
+ case AccountTaskState::STATE_CREATED:
+ case AccountTaskState::STATE_WORKING:
+ case AccountTaskState::STATE_SUCCEEDED: {
+ // Not reachable here, as they are not failures.
+ }
+ }
+ m_currentTask.reset();
+ emit activityChanged(false);
+}
+
+bool MinecraftAccount::isActive() const
+{
+ return m_currentTask;
+}
+
+bool MinecraftAccount::shouldRefresh() const
+{
+ /*
+ * Never refresh accounts that are being used by the game, it breaks the
+ * game session. Always refresh accounts that have not been refreshed yet
+ * during this session. Don't refresh broken accounts. Refresh accounts that
+ * would expire in the next 12 hours (fresh token validity is 24 hours).
+ */
+ if (isInUse()) {
+ return false;
+ }
+ switch (data.validity_) {
+ case Katabasis::Validity::Certain: {
+ break;
+ }
+ case Katabasis::Validity::None: {
+ return false;
+ }
+ case Katabasis::Validity::Assumed: {
+ return true;
+ }
+ }
+ auto now = QDateTime::currentDateTimeUtc();
+ auto issuedTimestamp = data.msaToken.issueInstant;
+ auto expiresTimestamp = data.msaToken.notAfter;
+
+ if (!expiresTimestamp.isValid()) {
+ expiresTimestamp = issuedTimestamp.addSecs(24 * 3600);
+ }
+ if (now.secsTo(expiresTimestamp) < (12 * 3600)) {
+ return true;
+ }
+ return false;
+}
+
+void MinecraftAccount::fillSession(AuthSessionPtr session)
+{
+ if (ownsMinecraft() && !hasProfile()) {
+ session->status = AuthSession::RequiresProfileSetup;
+ } else {
+ if (session->wants_online) {
+ session->status = AuthSession::PlayableOnline;
+ } else {
+ session->status = AuthSession::PlayableOffline;
+ }
+ }
+
+ // the user name
+ session->username = data.profileName();
+ // volatile auth token
+ session->access_token = data.accessToken();
+ // the semi-permanent client token
+ session->client_token = QString();
+ // profile name
+ session->player_name = data.profileName();
+ // profile ID
+ session->uuid = data.profileId();
+ // 'legacy' or 'mojang', depending on account type
+ session->user_type = typeString();
+ if (!session->access_token.isEmpty()) {
+ session->session =
+ "token:" + data.accessToken() + ":" + data.profileId();
+ } else {
+ session->session = "-";
+ }
+}
+
+void MinecraftAccount::decrementUses()
+{
+ Usable::decrementUses();
+ if (!isInUse()) {
+ emit changed();
+ // FIXME: we now need a better way to identify accounts...
+ qWarning() << "Profile" << data.profileId() << "is no longer in use.";
+ }
+}
+
+void MinecraftAccount::incrementUses()
+{
+ bool wasInUse = isInUse();
+ Usable::incrementUses();
+ if (!wasInUse) {
+ emit changed();
+ // FIXME: we now need a better way to identify accounts...
+ qWarning() << "Profile" << data.profileId() << "is now in use.";
+ }
+}
diff --git a/meshmc/launcher/minecraft/auth/MinecraftAccount.h b/meshmc/launcher/minecraft/auth/MinecraftAccount.h
new file mode 100644
index 0000000000..1d25fa7d57
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/MinecraftAccount.h
@@ -0,0 +1,198 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <QList>
+#include <QJsonObject>
+#include <QPair>
+#include <QMap>
+#include <QPixmap>
+
+#include <memory>
+
+#include "AuthSession.h"
+#include "Usable.h"
+#include "AccountData.h"
+#include "QObjectPtr.h"
+
+class Task;
+class AccountTask;
+class MinecraftAccount;
+
+typedef shared_qobject_ptr<MinecraftAccount> MinecraftAccountPtr;
+Q_DECLARE_METATYPE(MinecraftAccountPtr)
+
+/**
+ * A profile within someone's Mojang account.
+ *
+ * Currently, the profile system has not been implemented by Mojang yet,
+ * but we might as well add some things for it in MeshMC right now so
+ * we don't have to rip the code to pieces to add it later.
+ */
+struct AccountProfile {
+ QString id;
+ QString name;
+ bool legacy;
+};
+
+/**
+ * Object that stores information about a certain Mojang account.
+ *
+ * Said information may include things such as that account's username, client
+ * token, and access token if the user chose to stay logged in.
+ */
+class MinecraftAccount : public QObject, public Usable
+{
+ Q_OBJECT
+ public: /* construction */
+ //! Do not copy accounts. ever.
+ explicit MinecraftAccount(const MinecraftAccount& other,
+ QObject* parent) = delete;
+
+ //! Default constructor
+ explicit MinecraftAccount(QObject* parent = 0);
+
+ static MinecraftAccountPtr createBlankMSA();
+
+ static MinecraftAccountPtr loadFromJsonV3(const QJsonObject& json);
+
+ //! Saves a MinecraftAccount to a JSON object and returns it.
+ QJsonObject saveToJson() const;
+
+ public: /* manipulation */
+ shared_qobject_ptr<AccountTask> loginMSA();
+
+ shared_qobject_ptr<AccountTask> refresh();
+
+ shared_qobject_ptr<AccountTask> currentTask();
+
+ public: /* queries */
+ QString internalId() const
+ {
+ return data.internalId;
+ }
+
+ QString accountDisplayString() const
+ {
+ return data.accountDisplayString();
+ }
+
+ QString accessToken() const
+ {
+ return data.accessToken();
+ }
+
+ QString profileId() const
+ {
+ return data.profileId();
+ }
+
+ QString profileName() const
+ {
+ return data.profileName();
+ }
+
+ bool isActive() const;
+
+ bool isMSA() const
+ {
+ return data.type == AccountType::MSA;
+ }
+
+ bool ownsMinecraft() const
+ {
+ return data.minecraftEntitlement.ownsMinecraft;
+ }
+
+ bool hasProfile() const
+ {
+ return data.profileId().size() != 0;
+ }
+
+ QString typeString() const
+ {
+ return "msa";
+ }
+
+ QPixmap getFace() const;
+
+ //! Returns the current state of the account
+ AccountState accountState() const;
+
+ AccountData* accountData()
+ {
+ return &data;
+ }
+
+ bool shouldRefresh() const;
+
+ void fillSession(AuthSessionPtr session);
+
+ QString lastError() const
+ {
+ return data.lastError();
+ }
+
+ signals:
+ /**
+ * This signal is emitted when the account changes
+ */
+ void changed();
+
+ void activityChanged(bool active);
+
+ // TODO: better signalling for the various possible state changes -
+ // especially errors
+
+ protected: /* variables */
+ AccountData data;
+
+ // current task we are executing here
+ shared_qobject_ptr<AccountTask> m_currentTask;
+
+ protected: /* methods */
+ void incrementUses() override;
+ void decrementUses() override;
+
+ private slots:
+ void authSucceeded();
+ void authFailed(QString reason);
+};
diff --git a/meshmc/launcher/minecraft/auth/Parsers.cpp b/meshmc/launcher/minecraft/auth/Parsers.cpp
new file mode 100644
index 0000000000..6a4690942c
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/Parsers.cpp
@@ -0,0 +1,366 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "Parsers.h"
+
+#include <QJsonDocument>
+#include <QJsonArray>
+#include <QDebug>
+
+namespace Parsers
+{
+
+ bool getDateTime(QJsonValue value, QDateTime& out)
+ {
+ if (!value.isString()) {
+ return false;
+ }
+ out = QDateTime::fromString(value.toString(), Qt::ISODate);
+ return out.isValid();
+ }
+
+ bool getString(QJsonValue value, QString& out)
+ {
+ if (!value.isString()) {
+ return false;
+ }
+ out = value.toString();
+ return true;
+ }
+
+ bool getNumber(QJsonValue value, double& out)
+ {
+ if (!value.isDouble()) {
+ return false;
+ }
+ out = value.toDouble();
+ return true;
+ }
+
+ bool getNumber(QJsonValue value, int64_t& out)
+ {
+ if (!value.isDouble()) {
+ return false;
+ }
+ out = (int64_t)value.toDouble();
+ return true;
+ }
+
+ bool getBool(QJsonValue value, bool& out)
+ {
+ if (!value.isBool()) {
+ return false;
+ }
+ out = value.toBool();
+ return true;
+ }
+
+ /*
+ {
+ "IssueInstant":"2020-12-07T19:52:08.4463796Z",
+ "NotAfter":"2020-12-21T19:52:08.4463796Z",
+ "Token":"token",
+ "DisplayClaims":{
+ "xui":[
+ {
+ "uhs":"userhash"
+ }
+ ]
+ }
+ }
+ */
+ // TODO: handle error responses ...
+ /*
+ {
+ "Identity":"0",
+ "XErr":2148916238,
+ "Message":"",
+ "Redirect":"https://start.ui.xboxlive.com/AddChildToFamily"
+ }
+ // 2148916233 = missing XBox account
+ // 2148916238 = child account not linked to a family
+ */
+
+ bool parseXTokenResponse(QByteArray& data, Katabasis::Token& output,
+ QString name)
+ {
+ qDebug() << "Parsing" << name << ":";
+#ifndef NDEBUG
+ qDebug() << data;
+#endif
+ QJsonParseError jsonError;
+ QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
+ if (jsonError.error) {
+ qWarning() << "Failed to parse response from "
+ "user.auth.xboxlive.com as JSON: "
+ << jsonError.errorString();
+ return false;
+ }
+
+ auto obj = doc.object();
+ if (!getDateTime(obj.value("IssueInstant"), output.issueInstant)) {
+ qWarning() << "User IssueInstant is not a timestamp";
+ return false;
+ }
+ if (!getDateTime(obj.value("NotAfter"), output.notAfter)) {
+ qWarning() << "User NotAfter is not a timestamp";
+ return false;
+ }
+ if (!getString(obj.value("Token"), output.token)) {
+ qWarning() << "User Token is not a string";
+ return false;
+ }
+ auto arrayVal = obj.value("DisplayClaims").toObject().value("xui");
+ if (!arrayVal.isArray()) {
+ qWarning() << "Missing xui claims array";
+ return false;
+ }
+ bool foundUHS = false;
+ for (auto item : arrayVal.toArray()) {
+ if (!item.isObject()) {
+ continue;
+ }
+ auto obj = item.toObject();
+ if (obj.contains("uhs")) {
+ foundUHS = true;
+ } else {
+ continue;
+ }
+ // consume all 'display claims' ... whatever that means
+ for (auto iter = obj.begin(); iter != obj.end(); iter++) {
+ QString claim;
+ if (!getString(obj.value(iter.key()), claim)) {
+ qWarning() << "display claim " << iter.key()
+ << " is not a string...";
+ return false;
+ }
+ output.extra[iter.key()] = claim;
+ }
+
+ break;
+ }
+ if (!foundUHS) {
+ qWarning() << "Missing uhs";
+ return false;
+ }
+ output.validity = Katabasis::Validity::Certain;
+ qDebug() << name << "is valid.";
+ return true;
+ }
+
+ bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output)
+ {
+ qDebug() << "Parsing Minecraft profile...";
+#ifndef NDEBUG
+ qDebug() << data;
+#endif
+
+ QJsonParseError jsonError;
+ QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
+ if (jsonError.error) {
+ qWarning() << "Failed to parse response from "
+ "user.auth.xboxlive.com as JSON: "
+ << jsonError.errorString();
+ return false;
+ }
+
+ auto obj = doc.object();
+ if (!getString(obj.value("id"), output.id)) {
+ qWarning() << "Minecraft profile id is not a string";
+ return false;
+ }
+
+ if (!getString(obj.value("name"), output.name)) {
+ qWarning() << "Minecraft profile name is not a string";
+ return false;
+ }
+
+ auto skinsArray = obj.value("skins").toArray();
+ for (auto skin : skinsArray) {
+ auto skinObj = skin.toObject();
+ Skin skinOut;
+ if (!getString(skinObj.value("id"), skinOut.id)) {
+ continue;
+ }
+ QString state;
+ if (!getString(skinObj.value("state"), state)) {
+ continue;
+ }
+ if (state != "ACTIVE") {
+ continue;
+ }
+ if (!getString(skinObj.value("url"), skinOut.url)) {
+ continue;
+ }
+ if (!getString(skinObj.value("variant"), skinOut.variant)) {
+ continue;
+ }
+ // we deal with only the active skin
+ output.skin = skinOut;
+ break;
+ }
+ auto capesArray = obj.value("capes").toArray();
+
+ QString currentCape;
+ for (auto cape : capesArray) {
+ auto capeObj = cape.toObject();
+ Cape capeOut;
+ if (!getString(capeObj.value("id"), capeOut.id)) {
+ continue;
+ }
+ QString state;
+ if (!getString(capeObj.value("state"), state)) {
+ continue;
+ }
+ if (state == "ACTIVE") {
+ currentCape = capeOut.id;
+ }
+ if (!getString(capeObj.value("url"), capeOut.url)) {
+ continue;
+ }
+ if (!getString(capeObj.value("alias"), capeOut.alias)) {
+ continue;
+ }
+
+ output.capes[capeOut.id] = capeOut;
+ }
+ output.currentCape = currentCape;
+ output.validity = Katabasis::Validity::Certain;
+ return true;
+ }
+
+ bool parseMinecraftEntitlements(QByteArray& data,
+ MinecraftEntitlement& output)
+ {
+ qDebug() << "Parsing Minecraft entitlements...";
+#ifndef NDEBUG
+ qDebug() << data;
+#endif
+
+ QJsonParseError jsonError;
+ QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
+ if (jsonError.error) {
+ qWarning() << "Failed to parse response from "
+ "user.auth.xboxlive.com as JSON: "
+ << jsonError.errorString();
+ return false;
+ }
+
+ auto obj = doc.object();
+ output.canPlayMinecraft = false;
+ output.ownsMinecraft = false;
+
+ auto itemsArray = obj.value("items").toArray();
+ for (auto item : itemsArray) {
+ auto itemObj = item.toObject();
+ QString name;
+ if (!getString(itemObj.value("name"), name)) {
+ continue;
+ }
+ if (name == "game_minecraft") {
+ output.canPlayMinecraft = true;
+ }
+ if (name == "product_minecraft") {
+ output.ownsMinecraft = true;
+ }
+ }
+ output.validity = Katabasis::Validity::Certain;
+ return true;
+ }
+
+ bool parseRolloutResponse(QByteArray& data, bool& result)
+ {
+ qDebug() << "Parsing Rollout response...";
+#ifndef NDEBUG
+ qDebug() << data;
+#endif
+
+ QJsonParseError jsonError;
+ QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
+ if (jsonError.error) {
+ qWarning() << "Failed to parse response from "
+ "https://api.minecraftservices.com/rollout/v1/"
+ "msamigration as JSON: "
+ << jsonError.errorString();
+ return false;
+ }
+
+ auto obj = doc.object();
+ QString feature;
+ if (!getString(obj.value("feature"), feature)) {
+ qWarning() << "Rollout feature is not a string";
+ return false;
+ }
+ if (feature != "msamigration") {
+ qWarning() << "Rollout feature is not what we expected "
+ "(msamigration), but is instead \""
+ << feature << "\"";
+ return false;
+ }
+ if (!getBool(obj.value("rollout"), result)) {
+ qWarning() << "Rollout feature is not a string";
+ return false;
+ }
+ return true;
+ }
+
+ bool parseMojangResponse(QByteArray& data, Katabasis::Token& output)
+ {
+ QJsonParseError jsonError;
+ qDebug() << "Parsing Mojang response...";
+#ifndef NDEBUG
+ qDebug() << data;
+#endif
+ QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
+ if (jsonError.error) {
+ qWarning() << "Failed to parse response from "
+ "api.minecraftservices.com/launcher/login as JSON: "
+ << jsonError.errorString();
+ return false;
+ }
+
+ auto obj = doc.object();
+ double expires_in = 0;
+ if (!getNumber(obj.value("expires_in"), expires_in)) {
+ qWarning() << "expires_in is not a valid number";
+ return false;
+ }
+ auto currentTime = QDateTime::currentDateTimeUtc();
+ output.issueInstant = currentTime;
+ output.notAfter = currentTime.addSecs(expires_in);
+
+ QString username;
+ if (!getString(obj.value("username"), username)) {
+ qWarning() << "username is not valid";
+ return false;
+ }
+
+ // TODO: it's a JWT... validate it?
+ if (!getString(obj.value("access_token"), output.token)) {
+ qWarning() << "access_token is not valid";
+ return false;
+ }
+ output.validity = Katabasis::Validity::Certain;
+ qDebug() << "Mojang response is valid.";
+ return true;
+ }
+
+} // namespace Parsers
diff --git a/meshmc/launcher/minecraft/auth/Parsers.h b/meshmc/launcher/minecraft/auth/Parsers.h
new file mode 100644
index 0000000000..62fd056b92
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/Parsers.h
@@ -0,0 +1,42 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "AccountData.h"
+
+namespace Parsers
+{
+ bool getDateTime(QJsonValue value, QDateTime& out);
+ bool getString(QJsonValue value, QString& out);
+ bool getNumber(QJsonValue value, double& out);
+ bool getNumber(QJsonValue value, int64_t& out);
+ bool getBool(QJsonValue value, bool& out);
+
+ bool parseXTokenResponse(QByteArray& data, Katabasis::Token& output,
+ QString name);
+ bool parseMojangResponse(QByteArray& data, Katabasis::Token& output);
+
+ bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output);
+ bool parseMinecraftEntitlements(QByteArray& data,
+ MinecraftEntitlement& output);
+ bool parseRolloutResponse(QByteArray& data, bool& result);
+} // namespace Parsers
diff --git a/meshmc/launcher/minecraft/auth/flows/AuthFlow.cpp b/meshmc/launcher/minecraft/auth/flows/AuthFlow.cpp
new file mode 100644
index 0000000000..ef29e9e77f
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/flows/AuthFlow.cpp
@@ -0,0 +1,93 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <QNetworkAccessManager>
+#include <QNetworkRequest>
+#include <QNetworkReply>
+#include <QDebug>
+
+#include "AuthFlow.h"
+#include "katabasis/Globals.h"
+
+#include <Application.h>
+
+AuthFlow::AuthFlow(AccountData* data, QObject* parent)
+ : AccountTask(data, parent)
+{
+}
+
+void AuthFlow::succeed()
+{
+ m_data->validity_ = Katabasis::Validity::Certain;
+ changeState(AccountTaskState::STATE_SUCCEEDED,
+ tr("Finished all authentication steps"));
+}
+
+void AuthFlow::executeTask()
+{
+ if (m_currentStep) {
+ return;
+ }
+ changeState(AccountTaskState::STATE_WORKING, tr("Initializing"));
+ nextStep();
+}
+
+void AuthFlow::nextStep()
+{
+ if (m_steps.size() == 0) {
+ // we got to the end without an incident... assume this is all.
+ m_currentStep.reset();
+ succeed();
+ return;
+ }
+ m_currentStep = m_steps.front();
+ qDebug() << "AuthFlow:" << m_currentStep->describe();
+ m_steps.pop_front();
+ connect(m_currentStep.get(), &AuthStep::finished, this,
+ &AuthFlow::stepFinished);
+ connect(m_currentStep.get(), &AuthStep::authorizeWithBrowser, this,
+ &AuthFlow::authorizeWithBrowser);
+
+ m_currentStep->perform();
+}
+
+QString AuthFlow::getStateMessage() const
+{
+ switch (m_taskState) {
+ case AccountTaskState::STATE_WORKING: {
+ if (m_currentStep) {
+ return m_currentStep->describe();
+ } else {
+ return tr("Working...");
+ }
+ }
+ default: {
+ return AccountTask::getStateMessage();
+ }
+ }
+}
+
+void AuthFlow::stepFinished(AccountTaskState resultingState, QString message)
+{
+ if (changeState(resultingState, message)) {
+ nextStep();
+ }
+}
diff --git a/meshmc/launcher/minecraft/auth/flows/AuthFlow.h b/meshmc/launcher/minecraft/auth/flows/AuthFlow.h
new file mode 100644
index 0000000000..0a4a431b71
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/flows/AuthFlow.h
@@ -0,0 +1,64 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QList>
+#include <QVector>
+#include <QSet>
+#include <QNetworkReply>
+#include <QImage>
+
+#include "minecraft/auth/AccountData.h"
+#include "minecraft/auth/AccountTask.h"
+#include "minecraft/auth/AuthStep.h"
+
+class AuthFlow : public AccountTask
+{
+ Q_OBJECT
+
+ public:
+ explicit AuthFlow(AccountData* data, QObject* parent = 0);
+
+ Katabasis::Validity validity()
+ {
+ return m_data->validity_;
+ };
+
+ QString getStateMessage() const override;
+
+ void executeTask() override;
+
+ signals:
+ // No extra signals needed - authorizeWithBrowser is on AccountTask
+
+ private slots:
+ void stepFinished(AccountTaskState resultingState, QString message);
+
+ protected:
+ void succeed();
+ void nextStep();
+
+ protected:
+ QList<AuthStep::Ptr> m_steps;
+ AuthStep::Ptr m_currentStep;
+};
diff --git a/meshmc/launcher/minecraft/auth/flows/MSA.cpp b/meshmc/launcher/minecraft/auth/flows/MSA.cpp
new file mode 100644
index 0000000000..2b5908932a
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/flows/MSA.cpp
@@ -0,0 +1,65 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "MSA.h"
+
+#include "minecraft/auth/steps/MSAStep.h"
+#include "minecraft/auth/steps/XboxUserStep.h"
+#include "minecraft/auth/steps/XboxAuthorizationStep.h"
+#include "minecraft/auth/steps/MeshMCLoginStep.h"
+#include "minecraft/auth/steps/XboxProfileStep.h"
+#include "minecraft/auth/steps/EntitlementsStep.h"
+#include "minecraft/auth/steps/MinecraftProfileStep.h"
+#include "minecraft/auth/steps/GetSkinStep.h"
+
+MSASilent::MSASilent(AccountData* data, QObject* parent)
+ : AuthFlow(data, parent)
+{
+ m_steps.append(new MSAStep(m_data, MSAStep::Action::Refresh));
+ m_steps.append(new XboxUserStep(m_data));
+ m_steps.append(new XboxAuthorizationStep(m_data, &m_data->xboxApiToken,
+ "http://xboxlive.com", "Xbox"));
+ m_steps.append(
+ new XboxAuthorizationStep(m_data, &m_data->mojangservicesToken,
+ "rp://api.minecraftservices.com/", "Mojang"));
+ m_steps.append(new MeshMCLoginStep(m_data));
+ m_steps.append(new XboxProfileStep(m_data));
+ m_steps.append(new EntitlementsStep(m_data));
+ m_steps.append(new MinecraftProfileStep(m_data));
+ m_steps.append(new GetSkinStep(m_data));
+}
+
+MSAInteractive::MSAInteractive(AccountData* data, QObject* parent)
+ : AuthFlow(data, parent)
+{
+ m_steps.append(new MSAStep(m_data, MSAStep::Action::Login));
+ m_steps.append(new XboxUserStep(m_data));
+ m_steps.append(new XboxAuthorizationStep(m_data, &m_data->xboxApiToken,
+ "http://xboxlive.com", "Xbox"));
+ m_steps.append(
+ new XboxAuthorizationStep(m_data, &m_data->mojangservicesToken,
+ "rp://api.minecraftservices.com/", "Mojang"));
+ m_steps.append(new MeshMCLoginStep(m_data));
+ m_steps.append(new XboxProfileStep(m_data));
+ m_steps.append(new EntitlementsStep(m_data));
+ m_steps.append(new MinecraftProfileStep(m_data));
+ m_steps.append(new GetSkinStep(m_data));
+}
diff --git a/meshmc/launcher/minecraft/auth/flows/MSA.h b/meshmc/launcher/minecraft/auth/flows/MSA.h
new file mode 100644
index 0000000000..cdeb1ff30f
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/flows/MSA.h
@@ -0,0 +1,37 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include "AuthFlow.h"
+
+class MSAInteractive : public AuthFlow
+{
+ Q_OBJECT
+ public:
+ explicit MSAInteractive(AccountData* data, QObject* parent = 0);
+};
+
+class MSASilent : public AuthFlow
+{
+ Q_OBJECT
+ public:
+ explicit MSASilent(AccountData* data, QObject* parent = 0);
+};
diff --git a/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.cpp b/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.cpp
new file mode 100644
index 0000000000..8d0418042a
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.cpp
@@ -0,0 +1,80 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "EntitlementsStep.h"
+
+#include <QNetworkRequest>
+#include <QUuid>
+
+#include "minecraft/auth/AuthRequest.h"
+#include "minecraft/auth/Parsers.h"
+
+EntitlementsStep::EntitlementsStep(AccountData* data) : AuthStep(data) {}
+
+EntitlementsStep::~EntitlementsStep() noexcept = default;
+
+QString EntitlementsStep::describe()
+{
+ return tr("Determining game ownership.");
+}
+
+void EntitlementsStep::perform()
+{
+ auto uuid = QUuid::createUuid();
+ m_entitlementsRequestId = uuid.toString().remove('{').remove('}');
+ auto url =
+ "https://api.minecraftservices.com/entitlements/license?requestId=" +
+ m_entitlementsRequestId;
+ QNetworkRequest request = QNetworkRequest(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader("Accept", "application/json");
+ request.setRawHeader(
+ "Authorization",
+ QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8());
+ AuthRequest* requestor = new AuthRequest(this);
+ connect(requestor, &AuthRequest::finished, this,
+ &EntitlementsStep::onRequestDone);
+ requestor->get(request);
+ qDebug() << "Getting entitlements...";
+}
+
+void EntitlementsStep::rehydrate()
+{
+ // NOOP, for now. We only save bools and there's nothing to check.
+}
+
+void EntitlementsStep::onRequestDone(
+ QNetworkReply::NetworkError error, QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers)
+{
+ auto requestor = qobject_cast<AuthRequest*>(QObject::sender());
+ requestor->deleteLater();
+
+#ifndef NDEBUG
+ qDebug() << data;
+#endif
+
+ // TODO: check presence of same entitlementsRequestId?
+ // TODO: validate JWTs?
+ Parsers::parseMinecraftEntitlements(data, m_data->minecraftEntitlement);
+
+ emit finished(AccountTaskState::STATE_WORKING, tr("Got entitlements"));
+}
diff --git a/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.h b/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.h
new file mode 100644
index 0000000000..bd97a1c59e
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.h
@@ -0,0 +1,47 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QObject>
+
+#include "QObjectPtr.h"
+#include "minecraft/auth/AuthStep.h"
+
+class EntitlementsStep : public AuthStep
+{
+ Q_OBJECT
+
+ public:
+ explicit EntitlementsStep(AccountData* data);
+ virtual ~EntitlementsStep() noexcept;
+
+ void perform() override;
+ void rehydrate() override;
+
+ QString describe() override;
+
+ private slots:
+ void onRequestDone(QNetworkReply::NetworkError, QByteArray,
+ QList<QNetworkReply::RawHeaderPair>);
+
+ private:
+ QString m_entitlementsRequestId;
+};
diff --git a/meshmc/launcher/minecraft/auth/steps/GetSkinStep.cpp b/meshmc/launcher/minecraft/auth/steps/GetSkinStep.cpp
new file mode 100644
index 0000000000..abf5db950f
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/GetSkinStep.cpp
@@ -0,0 +1,64 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "GetSkinStep.h"
+
+#include <QNetworkRequest>
+
+#include "minecraft/auth/AuthRequest.h"
+#include "minecraft/auth/Parsers.h"
+
+GetSkinStep::GetSkinStep(AccountData* data) : AuthStep(data) {}
+
+GetSkinStep::~GetSkinStep() noexcept = default;
+
+QString GetSkinStep::describe()
+{
+ return tr("Getting skin.");
+}
+
+void GetSkinStep::perform()
+{
+ auto url = QUrl(m_data->minecraftProfile.skin.url);
+ QNetworkRequest request = QNetworkRequest(url);
+ AuthRequest* requestor = new AuthRequest(this);
+ connect(requestor, &AuthRequest::finished, this,
+ &GetSkinStep::onRequestDone);
+ requestor->get(request);
+}
+
+void GetSkinStep::rehydrate()
+{
+ // NOOP, for now.
+}
+
+void GetSkinStep::onRequestDone(QNetworkReply::NetworkError error,
+ QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers)
+{
+ auto requestor = qobject_cast<AuthRequest*>(QObject::sender());
+ requestor->deleteLater();
+
+ if (error == QNetworkReply::NoError) {
+ m_data->minecraftProfile.skin.data = data;
+ }
+ emit finished(AccountTaskState::STATE_SUCCEEDED, tr("Got skin"));
+}
diff --git a/meshmc/launcher/minecraft/auth/steps/GetSkinStep.h b/meshmc/launcher/minecraft/auth/steps/GetSkinStep.h
new file mode 100644
index 0000000000..ed6a288cdb
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/GetSkinStep.h
@@ -0,0 +1,44 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QObject>
+
+#include "QObjectPtr.h"
+#include "minecraft/auth/AuthStep.h"
+
+class GetSkinStep : public AuthStep
+{
+ Q_OBJECT
+
+ public:
+ explicit GetSkinStep(AccountData* data);
+ virtual ~GetSkinStep() noexcept;
+
+ void perform() override;
+ void rehydrate() override;
+
+ QString describe() override;
+
+ private slots:
+ void onRequestDone(QNetworkReply::NetworkError, QByteArray,
+ QList<QNetworkReply::RawHeaderPair>);
+};
diff --git a/meshmc/launcher/minecraft/auth/steps/MSAStep.cpp b/meshmc/launcher/minecraft/auth/steps/MSAStep.cpp
new file mode 100644
index 0000000000..9be4761549
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/MSAStep.cpp
@@ -0,0 +1,161 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "MSAStep.h"
+
+#include <QNetworkRequest>
+#include <QDesktopServices>
+
+#include "minecraft/auth/AuthRequest.h"
+#include "minecraft/auth/Parsers.h"
+
+#include "Application.h"
+
+MSAStep::MSAStep(AccountData* data, Action action)
+ : AuthStep(data), m_action(action)
+{
+ m_replyHandler = new QOAuthHttpServerReplyHandler(this);
+ m_replyHandler->setCallbackText(
+ tr("Login successful! You can close this page and return to MeshMC."));
+
+ m_oauth2 = new QOAuth2AuthorizationCodeFlow(this);
+ m_oauth2->setClientIdentifier(APPLICATION->msaClientId());
+ m_oauth2->setAuthorizationUrl(QUrl(
+ "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"));
+ m_oauth2->setTokenUrl(
+ QUrl("https://login.microsoftonline.com/consumers/oauth2/v2.0/token"));
+ m_oauth2->setScope("XboxLive.signin offline_access");
+ m_oauth2->setReplyHandler(m_replyHandler);
+ m_oauth2->setNetworkAccessManager(APPLICATION->network().get());
+
+ connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::granted, this,
+ &MSAStep::onGranted);
+ connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::requestFailed, this,
+ &MSAStep::onRequestFailed);
+ connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, this,
+ &MSAStep::onOpenBrowser);
+}
+
+MSAStep::~MSAStep() noexcept = default;
+
+QString MSAStep::describe()
+{
+ return tr("Logging in with Microsoft account.");
+}
+
+void MSAStep::rehydrate()
+{
+ switch (m_action) {
+ case Refresh: {
+ // TODO: check the tokens and see if they are old (older than a day)
+ return;
+ }
+ case Login: {
+ // NOOP
+ return;
+ }
+ }
+}
+
+void MSAStep::perform()
+{
+ switch (m_action) {
+ case Refresh: {
+ // Load the refresh token from stored account data
+ m_oauth2->setRefreshToken(m_data->msaToken.refresh_token);
+ m_oauth2->refreshTokens();
+ return;
+ }
+ case Login: {
+ *m_data = AccountData();
+ if (!m_replyHandler->isListening()) {
+ if (!m_replyHandler->listen(QHostAddress::LocalHost)) {
+ emit finished(AccountTaskState::STATE_FAILED_HARD,
+ tr("Failed to start local HTTP server for "
+ "OAuth2 callback."));
+ return;
+ }
+ }
+ m_oauth2->setModifyParametersFunction(
+ [](QAbstractOAuth::Stage stage,
+ QMultiMap<QString, QVariant>* parameters) {
+ if (stage ==
+ QAbstractOAuth::Stage::RequestingAuthorization) {
+ parameters->insert("prompt", "select_account");
+ }
+ });
+ m_oauth2->grant();
+ return;
+ }
+ }
+}
+
+void MSAStep::onOpenBrowser(const QUrl& url)
+{
+ emit authorizeWithBrowser(url);
+ QDesktopServices::openUrl(url);
+}
+
+void MSAStep::onGranted()
+{
+ m_replyHandler->close();
+
+ // Store the tokens in account data
+ m_data->msaToken.token = m_oauth2->token();
+ m_data->msaToken.refresh_token = m_oauth2->refreshToken();
+ m_data->msaToken.issueInstant = QDateTime::currentDateTimeUtc();
+ m_data->msaToken.notAfter = m_oauth2->expirationAt();
+ if (!m_data->msaToken.notAfter.isValid()) {
+ m_data->msaToken.notAfter = m_data->msaToken.issueInstant.addSecs(3600);
+ }
+ m_data->msaToken.validity = Katabasis::Validity::Certain;
+
+ emit finished(AccountTaskState::STATE_WORKING, tr("Got MSA token."));
+}
+
+void MSAStep::onRequestFailed(QAbstractOAuth::Error error)
+{
+ m_replyHandler->close();
+
+ switch (error) {
+ case QAbstractOAuth::Error::NetworkError:
+ emit finished(
+ AccountTaskState::STATE_OFFLINE,
+ tr("Microsoft authentication failed due to a network error."));
+ return;
+ case QAbstractOAuth::Error::ServerError:
+ case QAbstractOAuth::Error::OAuthTokenNotFoundError:
+ case QAbstractOAuth::Error::OAuthTokenSecretNotFoundError:
+ case QAbstractOAuth::Error::OAuthCallbackNotVerified:
+ emit finished(AccountTaskState::STATE_FAILED_HARD,
+ tr("Microsoft authentication failed."));
+ return;
+ case QAbstractOAuth::Error::ExpiredError:
+ emit finished(AccountTaskState::STATE_FAILED_GONE,
+ tr("Microsoft authentication token expired."));
+ return;
+ default:
+ emit finished(AccountTaskState::STATE_FAILED_HARD,
+ tr("Microsoft authentication failed with an "
+ "unrecognized error."));
+ return;
+ }
+}
diff --git a/meshmc/launcher/minecraft/auth/steps/MSAStep.h b/meshmc/launcher/minecraft/auth/steps/MSAStep.h
new file mode 100644
index 0000000000..2e223024e3
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/MSAStep.h
@@ -0,0 +1,55 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QObject>
+
+#include "QObjectPtr.h"
+#include "minecraft/auth/AuthStep.h"
+
+#include <QOAuth2AuthorizationCodeFlow>
+#include <QOAuthHttpServerReplyHandler>
+
+class MSAStep : public AuthStep
+{
+ Q_OBJECT
+ public:
+ enum Action { Refresh, Login };
+
+ public:
+ explicit MSAStep(AccountData* data, Action action);
+ virtual ~MSAStep() noexcept;
+
+ void perform() override;
+ void rehydrate() override;
+
+ QString describe() override;
+
+ private slots:
+ void onGranted();
+ void onRequestFailed(QAbstractOAuth::Error error);
+ void onOpenBrowser(const QUrl& url);
+
+ private:
+ QOAuth2AuthorizationCodeFlow* m_oauth2 = nullptr;
+ QOAuthHttpServerReplyHandler* m_replyHandler = nullptr;
+ Action m_action;
+};
diff --git a/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.cpp b/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.cpp
new file mode 100644
index 0000000000..19afcda3fc
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.cpp
@@ -0,0 +1,98 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "MeshMCLoginStep.h"
+
+#include <QNetworkRequest>
+
+#include "minecraft/auth/AuthRequest.h"
+#include "minecraft/auth/Parsers.h"
+#include "minecraft/auth/AccountTask.h"
+
+MeshMCLoginStep::MeshMCLoginStep(AccountData* data) : AuthStep(data) {}
+
+MeshMCLoginStep::~MeshMCLoginStep() noexcept = default;
+
+QString MeshMCLoginStep::describe()
+{
+ return tr("Accessing Mojang services.");
+}
+
+void MeshMCLoginStep::perform()
+{
+ auto requestURL = "https://api.minecraftservices.com/launcher/login";
+ auto uhs = m_data->mojangservicesToken.extra["uhs"].toString();
+ auto xToken = m_data->mojangservicesToken.token;
+
+ QString mc_auth_template = R"XXX(
+{
+ "xtoken": "XBL3.0 x=%1;%2",
+ "platform": "PC_LAUNCHER"
+}
+)XXX";
+ auto requestBody = mc_auth_template.arg(uhs, xToken);
+
+ QNetworkRequest request = QNetworkRequest(QUrl(requestURL));
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader("Accept", "application/json");
+ AuthRequest* requestor = new AuthRequest(this);
+ connect(requestor, &AuthRequest::finished, this,
+ &MeshMCLoginStep::onRequestDone);
+ requestor->post(request, requestBody.toUtf8());
+ qDebug() << "Getting Minecraft access token...";
+}
+
+void MeshMCLoginStep::rehydrate()
+{
+ // TODO: check the token validity
+}
+
+void MeshMCLoginStep::onRequestDone(QNetworkReply::NetworkError error,
+ QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers)
+{
+ auto requestor = qobject_cast<AuthRequest*>(QObject::sender());
+ requestor->deleteLater();
+
+ qDebug() << data;
+ if (error != QNetworkReply::NoError) {
+ qWarning() << "Reply error:" << error;
+#ifndef NDEBUG
+ qDebug() << data;
+#endif
+ emit finished(AccountTaskState::STATE_FAILED_SOFT,
+ tr("Failed to get Minecraft access token: %1")
+ .arg(requestor->errorString_));
+ return;
+ }
+
+ if (!Parsers::parseMojangResponse(data, m_data->yggdrasilToken)) {
+ qWarning() << "Could not parse login_with_xbox response...";
+#ifndef NDEBUG
+ qDebug() << data;
+#endif
+ emit finished(
+ AccountTaskState::STATE_FAILED_SOFT,
+ tr("Failed to parse the Minecraft access token response."));
+ return;
+ }
+ emit finished(AccountTaskState::STATE_WORKING, tr(""));
+}
diff --git a/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.h b/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.h
new file mode 100644
index 0000000000..859ae867f3
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.h
@@ -0,0 +1,44 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QObject>
+
+#include "QObjectPtr.h"
+#include "minecraft/auth/AuthStep.h"
+
+class MeshMCLoginStep : public AuthStep
+{
+ Q_OBJECT
+
+ public:
+ explicit MeshMCLoginStep(AccountData* data);
+ virtual ~MeshMCLoginStep() noexcept;
+
+ void perform() override;
+ void rehydrate() override;
+
+ QString describe() override;
+
+ private slots:
+ void onRequestDone(QNetworkReply::NetworkError, QByteArray,
+ QList<QNetworkReply::RawHeaderPair>);
+};
diff --git a/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp b/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp
new file mode 100644
index 0000000000..9955ff9738
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp
@@ -0,0 +1,101 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "MinecraftProfileStep.h"
+
+#include <QNetworkRequest>
+
+#include "minecraft/auth/AuthRequest.h"
+#include "minecraft/auth/Parsers.h"
+
+MinecraftProfileStep::MinecraftProfileStep(AccountData* data) : AuthStep(data)
+{
+}
+
+MinecraftProfileStep::~MinecraftProfileStep() noexcept = default;
+
+QString MinecraftProfileStep::describe()
+{
+ return tr("Fetching the Minecraft profile.");
+}
+
+void MinecraftProfileStep::perform()
+{
+ auto url = QUrl("https://api.minecraftservices.com/minecraft/profile");
+ QNetworkRequest request = QNetworkRequest(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader(
+ "Authorization",
+ QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8());
+
+ AuthRequest* requestor = new AuthRequest(this);
+ connect(requestor, &AuthRequest::finished, this,
+ &MinecraftProfileStep::onRequestDone);
+ requestor->get(request);
+}
+
+void MinecraftProfileStep::rehydrate()
+{
+ // NOOP, for now. We only save bools and there's nothing to check.
+}
+
+void MinecraftProfileStep::onRequestDone(
+ QNetworkReply::NetworkError error, QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers)
+{
+ auto requestor = qobject_cast<AuthRequest*>(QObject::sender());
+ requestor->deleteLater();
+
+#ifndef NDEBUG
+ qDebug() << data;
+#endif
+ if (error == QNetworkReply::ContentNotFoundError) {
+ // NOTE: Succeed even if we do not have a profile. This is a valid
+ // account state.
+ m_data->minecraftProfile = MinecraftProfile();
+ emit finished(AccountTaskState::STATE_SUCCEEDED,
+ tr("Account has no Minecraft profile."));
+ return;
+ }
+ if (error != QNetworkReply::NoError) {
+ qWarning() << "Error getting profile:";
+ qWarning() << " HTTP Status: " << requestor->httpStatus_;
+ qWarning() << " Internal error no.: " << error;
+ qWarning() << " Error string: " << requestor->errorString_;
+
+ qWarning() << " Response:";
+ qWarning() << QString::fromUtf8(data);
+
+ emit finished(AccountTaskState::STATE_FAILED_SOFT,
+ tr("Minecraft Java profile acquisition failed."));
+ return;
+ }
+ if (!Parsers::parseMinecraftProfile(data, m_data->minecraftProfile)) {
+ m_data->minecraftProfile = MinecraftProfile();
+ emit finished(
+ AccountTaskState::STATE_FAILED_SOFT,
+ tr("Minecraft Java profile response could not be parsed"));
+ return;
+ }
+
+ emit finished(AccountTaskState::STATE_WORKING,
+ tr("Minecraft Java profile acquisition succeeded."));
+}
diff --git a/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.h b/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.h
new file mode 100644
index 0000000000..eb0594bdf8
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.h
@@ -0,0 +1,44 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QObject>
+
+#include "QObjectPtr.h"
+#include "minecraft/auth/AuthStep.h"
+
+class MinecraftProfileStep : public AuthStep
+{
+ Q_OBJECT
+
+ public:
+ explicit MinecraftProfileStep(AccountData* data);
+ virtual ~MinecraftProfileStep() noexcept;
+
+ void perform() override;
+ void rehydrate() override;
+
+ QString describe() override;
+
+ private slots:
+ void onRequestDone(QNetworkReply::NetworkError, QByteArray,
+ QList<QNetworkReply::RawHeaderPair>);
+};
diff --git a/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp b/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp
new file mode 100644
index 0000000000..b54ad2a32b
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp
@@ -0,0 +1,191 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "XboxAuthorizationStep.h"
+
+#include <QNetworkRequest>
+#include <QJsonParseError>
+#include <QJsonDocument>
+
+#include "minecraft/auth/AuthRequest.h"
+#include "minecraft/auth/Parsers.h"
+
+XboxAuthorizationStep::XboxAuthorizationStep(AccountData* data,
+ Katabasis::Token* token,
+ QString relyingParty,
+ QString authorizationKind)
+ : AuthStep(data), m_token(token), m_relyingParty(relyingParty),
+ m_authorizationKind(authorizationKind)
+{
+}
+
+XboxAuthorizationStep::~XboxAuthorizationStep() noexcept = default;
+
+QString XboxAuthorizationStep::describe()
+{
+ return tr("Getting authorization to access %1 services.")
+ .arg(m_authorizationKind);
+}
+
+void XboxAuthorizationStep::rehydrate()
+{
+ // FIXME: check if the tokens are good?
+}
+
+void XboxAuthorizationStep::perform()
+{
+ QString xbox_auth_template = R"XXX(
+{
+ "Properties": {
+ "SandboxId": "RETAIL",
+ "UserTokens": [
+ "%1"
+ ]
+ },
+ "RelyingParty": "%2",
+ "TokenType": "JWT"
+}
+)XXX";
+ auto xbox_auth_data =
+ xbox_auth_template.arg(m_data->userToken.token, m_relyingParty);
+ // http://xboxlive.com
+ QNetworkRequest request =
+ QNetworkRequest(QUrl("https://xsts.auth.xboxlive.com/xsts/authorize"));
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader("Accept", "application/json");
+ AuthRequest* requestor = new AuthRequest(this);
+ connect(requestor, &AuthRequest::finished, this,
+ &XboxAuthorizationStep::onRequestDone);
+ requestor->post(request, xbox_auth_data.toUtf8());
+ qDebug() << "Getting authorization token for " << m_relyingParty;
+}
+
+void XboxAuthorizationStep::onRequestDone(
+ QNetworkReply::NetworkError error, QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers)
+{
+ auto requestor = qobject_cast<AuthRequest*>(QObject::sender());
+ requestor->deleteLater();
+
+#ifndef NDEBUG
+ qDebug() << data;
+#endif
+ if (error != QNetworkReply::NoError) {
+ qWarning() << "Reply error:" << error;
+ if (!processSTSError(error, data, headers)) {
+ emit finished(
+ AccountTaskState::STATE_FAILED_SOFT,
+ tr("Failed to get authorization for %1 services. Error %1.")
+ .arg(m_authorizationKind, error));
+ }
+ return;
+ }
+
+ Katabasis::Token temp;
+ if (!Parsers::parseXTokenResponse(data, temp, m_authorizationKind)) {
+ emit finished(AccountTaskState::STATE_FAILED_SOFT,
+ tr("Could not parse authorization response for access to "
+ "%1 services.")
+ .arg(m_authorizationKind));
+ return;
+ }
+
+ if (temp.extra["uhs"] != m_data->userToken.extra["uhs"]) {
+ emit finished(AccountTaskState::STATE_FAILED_SOFT,
+ tr("Server has changed %1 authorization user hash in the "
+ "reply. Something is wrong.")
+ .arg(m_authorizationKind));
+ return;
+ }
+ auto& token = *m_token;
+ token = temp;
+
+ emit finished(AccountTaskState::STATE_WORKING,
+ tr("Got authorization to access %1").arg(m_relyingParty));
+}
+
+bool XboxAuthorizationStep::processSTSError(
+ QNetworkReply::NetworkError error, QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers)
+{
+ if (error == QNetworkReply::AuthenticationRequiredError) {
+ QJsonParseError jsonError;
+ QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
+ if (jsonError.error) {
+ qWarning() << "Cannot parse error XSTS response as JSON: "
+ << jsonError.errorString();
+ emit finished(
+ AccountTaskState::STATE_FAILED_SOFT,
+ tr("Cannot parse %1 authorization error response as JSON: %2")
+ .arg(m_authorizationKind, jsonError.errorString()));
+ return true;
+ }
+
+ int64_t errorCode = -1;
+ auto obj = doc.object();
+ if (!Parsers::getNumber(obj.value("XErr"), errorCode)) {
+ emit finished(AccountTaskState::STATE_FAILED_SOFT,
+ tr("XErr element is missing from %1 authorization "
+ "error response.")
+ .arg(m_authorizationKind));
+ return true;
+ }
+ switch (errorCode) {
+ case 2148916233: {
+ emit finished(
+ AccountTaskState::STATE_FAILED_SOFT,
+ tr("This Microsoft account does not have an XBox Live "
+ "profile. Buy the game on %1 first.")
+ .arg("<a "
+ "href=\"https://www.minecraft.net/en-us/store/"
+ "minecraft-java-edition\">minecraft.net</a>"));
+ return true;
+ }
+ case 2148916235: {
+ // NOTE: this is the Grulovia error
+ emit finished(AccountTaskState::STATE_FAILED_SOFT,
+ tr("XBox Live is not available in your country. "
+ "You've been blocked."));
+ return true;
+ }
+ case 2148916238: {
+ emit finished(
+ AccountTaskState::STATE_FAILED_SOFT,
+ tr("This Microsoft account is underaged and is not linked "
+ "to a family.\n\nPlease set up your account according "
+ "to %1.")
+ .arg(
+ "<a "
+ "href=\"https://help.minecraft.net/hc/en-us/"
+ "articles/4403181904525\">help.minecraft.net</a>"));
+ return true;
+ }
+ default: {
+ emit finished(AccountTaskState::STATE_FAILED_SOFT,
+ tr("XSTS authentication ended with unrecognized "
+ "error(s):\n\n%1")
+ .arg(errorCode));
+ return true;
+ }
+ }
+ }
+ return false;
+}
diff --git a/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.h b/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.h
new file mode 100644
index 0000000000..a8413c939f
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.h
@@ -0,0 +1,55 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QObject>
+
+#include "QObjectPtr.h"
+#include "minecraft/auth/AuthStep.h"
+
+class XboxAuthorizationStep : public AuthStep
+{
+ Q_OBJECT
+
+ public:
+ explicit XboxAuthorizationStep(AccountData* data, Katabasis::Token* token,
+ QString relyingParty,
+ QString authorizationKind);
+ virtual ~XboxAuthorizationStep() noexcept;
+
+ void perform() override;
+ void rehydrate() override;
+
+ QString describe() override;
+
+ private:
+ bool processSTSError(QNetworkReply::NetworkError error, QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers);
+
+ private slots:
+ void onRequestDone(QNetworkReply::NetworkError, QByteArray,
+ QList<QNetworkReply::RawHeaderPair>);
+
+ private:
+ Katabasis::Token* m_token;
+ QString m_relyingParty;
+ QString m_authorizationKind;
+};
diff --git a/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.cpp b/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.cpp
new file mode 100644
index 0000000000..aae94b0403
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.cpp
@@ -0,0 +1,96 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "XboxProfileStep.h"
+
+#include <QNetworkRequest>
+#include <QUrlQuery>
+
+#include "minecraft/auth/AuthRequest.h"
+#include "minecraft/auth/Parsers.h"
+
+XboxProfileStep::XboxProfileStep(AccountData* data) : AuthStep(data) {}
+
+XboxProfileStep::~XboxProfileStep() noexcept = default;
+
+QString XboxProfileStep::describe()
+{
+ return tr("Fetching Xbox profile.");
+}
+
+void XboxProfileStep::rehydrate()
+{
+ // NOOP, for now. We only save bools and there's nothing to check.
+}
+
+void XboxProfileStep::perform()
+{
+ auto url = QUrl("https://profile.xboxlive.com/users/me/profile/settings");
+ QUrlQuery q;
+ q.addQueryItem(
+ "settings",
+ "GameDisplayName,AppDisplayName,AppDisplayPicRaw,GameDisplayPicRaw,"
+ "PublicGamerpic,ShowUserAsAvatar,Gamerscore,Gamertag,ModernGamertag,"
+ "ModernGamertagSuffix,"
+ "UniqueModernGamertag,AccountTier,TenureLevel,XboxOneRep,"
+ "PreferredColor,Location,Bio,Watermarks,"
+ "RealName,RealNameOverride,IsQuarantined");
+ url.setQuery(q);
+
+ QNetworkRequest request = QNetworkRequest(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader("Accept", "application/json");
+ request.setRawHeader("x-xbl-contract-version", "3");
+ request.setRawHeader("Authorization",
+ QString("XBL3.0 x=%1;%2")
+ .arg(m_data->userToken.extra["uhs"].toString(),
+ m_data->xboxApiToken.token)
+ .toUtf8());
+ AuthRequest* requestor = new AuthRequest(this);
+ connect(requestor, &AuthRequest::finished, this,
+ &XboxProfileStep::onRequestDone);
+ requestor->get(request);
+ qDebug() << "Getting Xbox profile...";
+}
+
+void XboxProfileStep::onRequestDone(QNetworkReply::NetworkError error,
+ QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers)
+{
+ auto requestor = qobject_cast<AuthRequest*>(QObject::sender());
+ requestor->deleteLater();
+
+ if (error != QNetworkReply::NoError) {
+ qWarning() << "Reply error:" << error;
+#ifndef NDEBUG
+ qDebug() << data;
+#endif
+ finished(AccountTaskState::STATE_FAILED_SOFT,
+ tr("Failed to retrieve the Xbox profile."));
+ return;
+ }
+
+#ifndef NDEBUG
+ qDebug() << "XBox profile: " << data;
+#endif
+
+ emit finished(AccountTaskState::STATE_WORKING, tr("Got Xbox profile"));
+}
diff --git a/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.h b/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.h
new file mode 100644
index 0000000000..cf2c0c3c9b
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.h
@@ -0,0 +1,44 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QObject>
+
+#include "QObjectPtr.h"
+#include "minecraft/auth/AuthStep.h"
+
+class XboxProfileStep : public AuthStep
+{
+ Q_OBJECT
+
+ public:
+ explicit XboxProfileStep(AccountData* data);
+ virtual ~XboxProfileStep() noexcept;
+
+ void perform() override;
+ void rehydrate() override;
+
+ QString describe() override;
+
+ private slots:
+ void onRequestDone(QNetworkReply::NetworkError, QByteArray,
+ QList<QNetworkReply::RawHeaderPair>);
+};
diff --git a/meshmc/launcher/minecraft/auth/steps/XboxUserStep.cpp b/meshmc/launcher/minecraft/auth/steps/XboxUserStep.cpp
new file mode 100644
index 0000000000..77afa17fb9
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/XboxUserStep.cpp
@@ -0,0 +1,93 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "XboxUserStep.h"
+
+#include <QNetworkRequest>
+
+#include "minecraft/auth/AuthRequest.h"
+#include "minecraft/auth/Parsers.h"
+
+XboxUserStep::XboxUserStep(AccountData* data) : AuthStep(data) {}
+
+XboxUserStep::~XboxUserStep() noexcept = default;
+
+QString XboxUserStep::describe()
+{
+ return tr("Logging in as an Xbox user.");
+}
+
+void XboxUserStep::rehydrate()
+{
+ // NOOP, for now. We only save bools and there's nothing to check.
+}
+
+void XboxUserStep::perform()
+{
+ QString xbox_auth_template = R"XXX(
+{
+ "Properties": {
+ "AuthMethod": "RPS",
+ "SiteName": "user.auth.xboxlive.com",
+ "RpsTicket": "d=%1"
+ },
+ "RelyingParty": "http://auth.xboxlive.com",
+ "TokenType": "JWT"
+}
+)XXX";
+ auto xbox_auth_data = xbox_auth_template.arg(m_data->msaToken.token);
+
+ QNetworkRequest request = QNetworkRequest(
+ QUrl("https://user.auth.xboxlive.com/user/authenticate"));
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader("Accept", "application/json");
+ auto* requestor = new AuthRequest(this);
+ connect(requestor, &AuthRequest::finished, this,
+ &XboxUserStep::onRequestDone);
+ requestor->post(request, xbox_auth_data.toUtf8());
+ qDebug() << "First layer of XBox auth ... commencing.";
+}
+
+void XboxUserStep::onRequestDone(QNetworkReply::NetworkError error,
+ QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers)
+{
+ auto requestor = qobject_cast<AuthRequest*>(QObject::sender());
+ requestor->deleteLater();
+
+ if (error != QNetworkReply::NoError) {
+ qWarning() << "Reply error:" << error;
+ emit finished(AccountTaskState::STATE_FAILED_SOFT,
+ tr("XBox user authentication failed."));
+ return;
+ }
+
+ Katabasis::Token temp;
+ if (!Parsers::parseXTokenResponse(data, temp, "UToken")) {
+ qWarning() << "Could not parse user authentication response...";
+ emit finished(
+ AccountTaskState::STATE_FAILED_SOFT,
+ tr("XBox user authentication response could not be understood."));
+ return;
+ }
+ m_data->userToken = temp;
+ emit finished(AccountTaskState::STATE_WORKING, tr("Got Xbox user token"));
+}
diff --git a/meshmc/launcher/minecraft/auth/steps/XboxUserStep.h b/meshmc/launcher/minecraft/auth/steps/XboxUserStep.h
new file mode 100644
index 0000000000..d783b534c9
--- /dev/null
+++ b/meshmc/launcher/minecraft/auth/steps/XboxUserStep.h
@@ -0,0 +1,44 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QObject>
+
+#include "QObjectPtr.h"
+#include "minecraft/auth/AuthStep.h"
+
+class XboxUserStep : public AuthStep
+{
+ Q_OBJECT
+
+ public:
+ explicit XboxUserStep(AccountData* data);
+ virtual ~XboxUserStep() noexcept;
+
+ void perform() override;
+ void rehydrate() override;
+
+ QString describe() override;
+
+ private slots:
+ void onRequestDone(QNetworkReply::NetworkError, QByteArray,
+ QList<QNetworkReply::RawHeaderPair>);
+};