summaryrefslogtreecommitdiff
path: root/archived/projt-launcher/launcher/minecraft/auth
diff options
context:
space:
mode:
Diffstat (limited to 'archived/projt-launcher/launcher/minecraft/auth')
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/AccountData.cpp479
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/AccountData.hpp190
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/AccountList.cpp776
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/AccountList.hpp202
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/AuthFlow.cpp438
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/AuthFlow.hpp107
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/AuthSession.cpp63
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/AuthSession.hpp69
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/MinecraftAccount.cpp374
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/MinecraftAccount.hpp243
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/Parsers.cpp597
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/Parsers.hpp40
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/Credentials.hpp233
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/DeviceCodeAuthStep.cpp323
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/DeviceCodeAuthStep.hpp83
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/GameEntitlementsStep.cpp145
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/GameEntitlementsStep.hpp69
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/MicrosoftOAuthStep.cpp319
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/MicrosoftOAuthStep.hpp85
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftProfileFetchStep.cpp170
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftProfileFetchStep.hpp68
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftServicesLoginStep.cpp138
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftServicesLoginStep.hpp65
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/SkinDownloadStep.cpp76
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/SkinDownloadStep.hpp63
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/Step.hpp128
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/Steps.hpp51
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/XboxLiveUserStep.cpp147
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/XboxLiveUserStep.hpp65
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/XboxProfileFetchStep.cpp155
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/XboxProfileFetchStep.hpp67
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/XboxSecurityTokenStep.cpp249
-rw-r--r--archived/projt-launcher/launcher/minecraft/auth/steps/XboxSecurityTokenStep.hpp89
33 files changed, 6366 insertions, 0 deletions
diff --git a/archived/projt-launcher/launcher/minecraft/auth/AccountData.cpp b/archived/projt-launcher/launcher/minecraft/auth/AccountData.cpp
new file mode 100644
index 0000000000..7a6bce3ffc
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/AccountData.cpp
@@ -0,0 +1,479 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * === Upstream License Block (Do Not Modify) ==============================
+ *
+ * Prism Launcher - Minecraft Launcher
+ * Copyright (C) 2022 Sefa Eyeoglu <contact@scrumplex.net>
+ *
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * 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 "AccountData.hpp"
+#include <QDebug>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QUuid>
+
+namespace
+{
+ void tokenToJSONV3(QJsonObject& parent, const 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;
+ }
+ }
+
+ Token tokenFromJSONV3(const QJsonObject& parent, const char* tokenName)
+ {
+ 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 = 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.url.replace("http://textures.minecraft.net", "https://textures.minecraft.net");
+ out.skin.variant = variantV.toString();
+
+ // data for skin is optional
+ auto dataV = skinObj.value("data");
+ if (dataV.isString())
+ {
+ auto base64Result = QByteArray::fromBase64Encoding(dataV.toString().toLatin1());
+ if (base64Result.decodingStatus != QByteArray::Base64DecodingStatus::Ok)
+ {
+ qWarning() << "skin data is not valid base64";
+ return MinecraftProfile();
+ }
+ out.skin.data = base64Result.decoded;
+ }
+ 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.url.replace("http://textures.minecraft.net", "https://textures.minecraft.net");
+ cape.alias = aliasV.toString();
+
+ // data for cape is optional.
+ auto dataV = capeObj.value("data");
+ if (dataV.isString())
+ {
+ auto base64Result = QByteArray::fromBase64Encoding(dataV.toString().toLatin1());
+ if (base64Result.decodingStatus != QByteArray::Base64DecodingStatus::Ok)
+ {
+ qWarning() << "cape data is not valid base64";
+ return MinecraftProfile();
+ }
+ cape.data = base64Result.decoded;
+ }
+ 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 = Validity::Assumed;
+ return out;
+ }
+
+ void entitlementToJSONV3(QJsonObject& parent, MinecraftEntitlement p)
+ {
+ if (p.validity == 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 = 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 if (typeS == "Offline")
+ {
+ type = AccountType::Offline;
+ }
+ else
+ {
+ qWarning() << "Failed to parse account data: type is not recognized.";
+ return false;
+ }
+
+ if (type == AccountType::MSA)
+ {
+ auto clientIDV = data.value("msa-client-id");
+ if (clientIDV.isString())
+ {
+ msaClientID = clientIDV.toString();
+ } // leave msaClientID empty if it doesn't exist or isn't a string
+ msaToken = tokenFromJSONV3(data, "msa");
+ userToken = tokenFromJSONV3(data, "utoken");
+ xboxApiToken = tokenFromJSONV3(data, "xrp-main");
+ mojangservicesToken = tokenFromJSONV3(data, "xrp-mc");
+ }
+
+ yggdrasilToken = tokenFromJSONV3(data, "ygg");
+ // versions before 7.2 used "offline" as the offline token
+ if (yggdrasilToken.token == "offline")
+ yggdrasilToken.token = "0";
+
+ minecraftProfile = profileFromJSONV3(data, "profile");
+ if (!entitlementFromJSONV3(data, minecraftEntitlement))
+ {
+ if (minecraftProfile.validity != Validity::None)
+ {
+ minecraftEntitlement.canPlayMinecraft = true;
+ minecraftEntitlement.ownsMinecraft = true;
+ minecraftEntitlement.validity = Validity::Assumed;
+ }
+ }
+
+ validity_ = minecraftProfile.validity;
+ return true;
+}
+
+QJsonObject AccountData::saveState() const
+{
+ QJsonObject output;
+ if (type == AccountType::MSA)
+ {
+ output["type"] = "MSA";
+ output["msa-client-id"] = msaClientID;
+ tokenToJSONV3(output, msaToken, "msa");
+ tokenToJSONV3(output, userToken, "utoken");
+ tokenToJSONV3(output, xboxApiToken, "xrp-main");
+ tokenToJSONV3(output, mojangservicesToken, "xrp-mc");
+ }
+ else if (type == AccountType::Offline)
+ {
+ output["type"] = "Offline";
+ }
+
+ 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
+{
+ switch (type)
+ {
+ case AccountType::Offline:
+ {
+ return QObject::tr("<Offline>");
+ }
+ case AccountType::MSA:
+ {
+ if (xboxApiToken.extra.contains("gtg"))
+ {
+ return xboxApiToken.extra["gtg"].toString();
+ }
+ return "Xbox profile missing";
+ }
+ default:
+ {
+ return "Invalid Account";
+ }
+ }
+}
+
+QString AccountData::lastError() const
+{
+ return errorString;
+}
diff --git a/archived/projt-launcher/launcher/minecraft/auth/AccountData.hpp b/archived/projt-launcher/launcher/minecraft/auth/AccountData.hpp
new file mode 100644
index 0000000000..f30d77225f
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/AccountData.hpp
@@ -0,0 +1,190 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * === Upstream License Block (Do Not Modify) ==============================
+ *
+ * Prism Launcher - Minecraft Launcher
+ * Copyright (C) 2022 Sefa Eyeoglu <contact@scrumplex.net>
+ *
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * 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 <QByteArray>
+#include <QJsonObject>
+#include <QList>
+#include <QString>
+
+#include <QDateTime>
+#include <QMap>
+#include <QString>
+#include <QVariantMap>
+
+enum class Validity
+{
+ None,
+ Assumed,
+ Certain
+};
+
+struct Token
+{
+ QDateTime issueInstant;
+ QDateTime notAfter;
+ QString token;
+ QString refresh_token;
+ QVariantMap extra;
+
+ Validity validity = Validity::None;
+ bool persistent = true;
+};
+
+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;
+ Validity validity = Validity::None;
+};
+
+struct MinecraftProfile
+{
+ QString id;
+ QString name;
+ Skin skin;
+ QString currentCape;
+ QMap<QString, Cape> capes;
+ Validity validity = Validity::None;
+};
+
+enum class AccountType
+{
+ MSA,
+ Offline
+};
+
+enum class AccountState
+{
+ Unchecked,
+ Offline,
+ Working,
+ Online,
+ Disabled,
+ Errored,
+ Expired,
+ Gone
+};
+
+/**
+ * State of an authentication task.
+ * Used by AuthFlow to communicate progress and results.
+ */
+enum class AccountTaskState
+{
+ STATE_CREATED,
+ STATE_WORKING,
+ STATE_SUCCEEDED,
+ STATE_OFFLINE,
+ STATE_DISABLED,
+ STATE_FAILED_SOFT,
+ STATE_FAILED_HARD,
+ STATE_FAILED_GONE
+};
+
+struct AccountData
+{
+ QJsonObject saveState() const;
+ bool resumeStateFromV3(QJsonObject data);
+
+ //! userName for Mojang accounts, 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;
+
+ QString msaClientID;
+ Token msaToken;
+ Token userToken;
+ Token xboxApiToken;
+ Token mojangservicesToken;
+
+ Token yggdrasilToken;
+ MinecraftProfile minecraftProfile;
+ MinecraftEntitlement minecraftEntitlement;
+ Validity validity_ = Validity::None;
+
+ // runtime only information (not saved with the account)
+ QString internalId;
+ QString errorString;
+ AccountState accountState = AccountState::Unchecked;
+};
diff --git a/archived/projt-launcher/launcher/minecraft/auth/AccountList.cpp b/archived/projt-launcher/launcher/minecraft/auth/AccountList.cpp
new file mode 100644
index 0000000000..c68d214aad
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/AccountList.cpp
@@ -0,0 +1,776 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * === Upstream License Block (Do Not Modify) ==============================
+ *
+ * Prism Launcher - Minecraft Launcher
+ * Copyright (C) 2022 Sefa Eyeoglu <contact@scrumplex.net>
+ *
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * 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.hpp"
+#include "AccountData.hpp"
+#include "tasks/Task.h"
+
+#include <QDir>
+#include <QFile>
+#include <QIODevice>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonParseError>
+#include <QObject>
+#include <QString>
+#include <QTextStream>
+#include <QTimer>
+
+#include <QDebug>
+
+#include <FileSystem.h>
+#include <QSaveFile>
+
+enum AccountListVersion
+{
+ 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. We shouldn't let it continue
+ // because of the signal / slot connections after this.
+ if (m_accounts.contains(account))
+ {
+ qDebug() << "Tried to add account that's already on the accounts list!";
+ 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)
+ {
+ qDebug() << "Replacing old account with a new one with the same profile ID!";
+
+ 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();
+ qDebug() << "Inserting account at index" << row;
+
+ 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)
+ {
+ if (!saveList())
+ {
+ qWarning() << "Failed to save account list automatically";
+ emit fileSaveFailed(m_listFilePath);
+ }
+ }
+
+ emit listChanged();
+}
+
+void AccountList::onDefaultAccountChanged()
+{
+ if (m_autosave)
+ saveList();
+
+ emit defaultAccountChanged();
+}
+
+int AccountList::count() const
+{
+ return m_accounts.count();
+}
+
+QString getAccountStatus(AccountState status)
+{
+ switch (status)
+ {
+ case AccountState::Unchecked: return QObject::tr("Unchecked", "Account status");
+ case AccountState::Offline: return QObject::tr("Offline", "Account status");
+ case AccountState::Online: return QObject::tr("Ready", "Account status");
+ case AccountState::Working: return QObject::tr("Working", "Account status");
+ case AccountState::Errored: return QObject::tr("Errored", "Account status");
+ case AccountState::Expired: return QObject::tr("Expired", "Account status");
+ case AccountState::Disabled: return QObject::tr("Disabled", "Account status");
+ case AccountState::Gone: return QObject::tr("Gone", "Account status");
+ default: return QObject::tr("Unknown", "Account status");
+ }
+}
+
+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 IconColumn: return QVariant(); // Icons are handled by DecorationRole
+ case ProfileNameColumn: return account->profileName();
+ case NameColumn: return account->accountDisplayString();
+ case TypeColumn:
+ {
+ switch (account->accountType())
+ {
+ case AccountType::MSA:
+ {
+ return tr("MSA", "Account type");
+ }
+ case AccountType::Offline:
+ {
+ return tr("Offline", "Account type");
+ }
+ }
+ return tr("Unknown", "Account type");
+ }
+ case StatusColumn: return getAccountStatus(account->accountState());
+ default: return QVariant();
+ }
+
+ case Qt::DecorationRole:
+ if (index.column() == IconColumn)
+ {
+ return account->getFace();
+ }
+ return QVariant();
+
+ case Qt::ToolTipRole: return account->accountDisplayString();
+
+ case PointerRole: return QVariant::fromValue(account);
+
+ case Qt::CheckStateRole:
+ if (index.column() == ProfileNameColumn)
+ return account == m_defaultAccount ? Qt::Checked : Qt::Unchecked;
+ return QVariant();
+
+ default: return QVariant();
+ }
+}
+
+QVariant AccountList::headerData(int section, [[maybe_unused]] Qt::Orientation orientation, int role) const
+{
+ switch (role)
+ {
+ case Qt::DisplayRole:
+ switch (section)
+ {
+ case IconColumn: return QVariant(); // No header text for icon column
+ case ProfileNameColumn: return tr("Username");
+ case NameColumn: return tr("Account");
+ case TypeColumn: return tr("Type");
+ case StatusColumn: return tr("Status");
+ default: return QVariant();
+ }
+
+ case Qt::ToolTipRole:
+ switch (section)
+ {
+ case IconColumn: return tr("Account avatar");
+ case ProfileNameColumn: return tr("Minecraft username associated with the account.");
+ case NameColumn: return tr("User name of the account.");
+ case TypeColumn: return tr("Type of the account (MSA or Offline)");
+ case StatusColumn: return tr("Current status of the account.");
+ default: return QVariant();
+ }
+
+ default: return QVariant();
+ }
+}
+
+int AccountList::rowCount(const QModelIndex& parent) const
+{
+ // Return count
+ return parent.isValid() ? 0 : count();
+}
+
+int AccountList::columnCount(const QModelIndex& parent) const
+{
+ return parent.isValid() ? 0 : NUM_COLUMNS;
+}
+
+Qt::ItemFlags AccountList::flags(const QModelIndex& index) const
+{
+ if (index.row() < 0 || index.row() >= rowCount(index.parent()) || !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.parent()) || !idx.isValid())
+ {
+ return false;
+ }
+
+ if (role == Qt::CheckStateRole)
+ {
+ if (value == Qt::Checked)
+ {
+ MinecraftAccountPtr account = at(idx.row());
+ setDefaultAccount(account);
+ }
+ else if (m_defaultAccount == at(idx.row()))
+ setDefaultAccount(nullptr);
+ }
+
+ emit dataChanged(idx, index(idx.row(), columnCount(QModelIndex()) - 1));
+ return true;
+}
+
+bool AccountList::loadList()
+{
+ if (m_listFilePath.isEmpty())
+ {
+ qCritical() << "Can't load Mojang 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.
+ if (!file.open(QIODevice::ReadOnly))
+ {
+ qCritical() << QString("Failed to read the account list file (%1): %2")
+ .arg(m_listFilePath, file.errorString())
+ .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();
+ if (listVersion == AccountListVersion::MojangMSA)
+ return loadV3(root);
+
+ 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 Mojang 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.
+ if (!file.open(QIODevice::WriteOnly))
+ {
+ qCritical() << QString("Failed to write the account list file (%1): %2")
+ .arg(m_listFilePath, file.errorString())
+ .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(), &Task::succeeded, this, &AccountList::authSucceeded);
+ connect(m_currentTask.get(), &Task::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/archived/projt-launcher/launcher/minecraft/auth/AccountList.hpp b/archived/projt-launcher/launcher/minecraft/auth/AccountList.hpp
new file mode 100644
index 0000000000..107c4bb286
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/AccountList.hpp
@@ -0,0 +1,202 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * === Upstream License Block (Do Not Modify) ==============================
+ *
+ * Prism Launcher - Minecraft Launcher
+ * Copyright (C) 2022 Sefa Eyeoglu <contact@scrumplex.net>
+ *
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * 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.hpp"
+#include "minecraft/auth/AuthFlow.hpp"
+
+#include <QAbstractListModel>
+#include <QObject>
+#include <QSharedPointer>
+#include <QVariant>
+
+/*!
+ * List of available Mojang accounts.
+ * This should be loaded in the background by ProjT Launcher on startup.
+ */
+class AccountList : public QAbstractListModel
+{
+ Q_OBJECT
+ public:
+ enum ModelRoles
+ {
+ PointerRole = 0x34B1CB48
+ };
+
+ enum VListColumns
+ {
+ IconColumn = 0,
+ ProfileNameColumn,
+ NameColumn,
+ 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(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);
+ void fileSaveFailed(QString path);
+
+ 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<AuthFlow> 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/archived/projt-launcher/launcher/minecraft/auth/AuthFlow.cpp b/archived/projt-launcher/launcher/minecraft/auth/AuthFlow.cpp
new file mode 100644
index 0000000000..7b20dc5a3f
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/AuthFlow.cpp
@@ -0,0 +1,438 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "AuthFlow.hpp"
+
+#include <QDebug>
+
+#include "Application.h"
+#include "minecraft/auth/steps/Steps.hpp"
+
+AuthFlow::AuthFlow(AccountData* data, Action action) : Task(), m_legacyData(data)
+{
+ Q_ASSERT(data != nullptr);
+
+ // Initialize credentials from legacy data if refreshing
+ if (action == Action::Refresh && data)
+ {
+ m_credentials.msaClientId = data->msaClientID;
+ m_credentials.msaToken.accessToken = data->msaToken.token;
+ m_credentials.msaToken.refreshToken = data->msaToken.refresh_token;
+ m_credentials.msaToken.issuedAt = data->msaToken.issueInstant;
+ m_credentials.msaToken.expiresAt = data->msaToken.notAfter;
+ m_credentials.msaToken.metadata = data->msaToken.extra;
+ }
+
+ m_pipelineValid = buildPipeline(action);
+
+ if (!m_pipelineValid)
+ {
+ qWarning() << "AuthFlow: Pipeline build failed for account type" << static_cast<int>(data->type);
+ }
+
+ updateState(AccountTaskState::STATE_CREATED);
+}
+
+bool AuthFlow::buildPipeline(Action action)
+{
+ // Explicit handling of non-MSA accounts
+ if (m_legacyData->type == AccountType::Offline)
+ {
+ qDebug() << "AuthFlow: Offline account does not require authentication pipeline";
+ // Offline accounts don't need auth steps - this is valid, not an error
+ // The caller should check account type before creating AuthFlow
+ return false;
+ }
+
+ if (m_legacyData->type != AccountType::MSA)
+ {
+ qWarning() << "AuthFlow: Unsupported account type:" << static_cast<int>(m_legacyData->type);
+ return false;
+ }
+
+ // Step 1: Microsoft Authentication
+ if (action == Action::DeviceCode)
+ {
+ auto* deviceCodeStep = new projt::minecraft::auth::DeviceCodeAuthStep(m_credentials);
+ connect(deviceCodeStep,
+ &projt::minecraft::auth::DeviceCodeAuthStep::deviceCodeReady,
+ this,
+ &AuthFlow::authorizeWithBrowserWithExtra);
+ connect(this, &Task::aborted, deviceCodeStep, &projt::minecraft::auth::DeviceCodeAuthStep::cancel);
+ m_steps.append(projt::minecraft::auth::Step::Ptr(deviceCodeStep));
+ }
+ else
+ {
+ auto* oauthStep = new projt::minecraft::auth::MicrosoftOAuthStep(m_credentials, action == Action::Refresh);
+ connect(oauthStep,
+ &projt::minecraft::auth::MicrosoftOAuthStep::browserAuthRequired,
+ this,
+ &AuthFlow::authorizeWithBrowser);
+ m_steps.append(projt::minecraft::auth::Step::Ptr(oauthStep));
+ }
+
+ // Step 2: Xbox Live User Token
+ m_steps.append(projt::minecraft::auth::Step::Ptr(new projt::minecraft::auth::XboxLiveUserStep(m_credentials)));
+
+ // Step 3: Xbox XSTS Token for Xbox Live services
+ m_steps.append(projt::minecraft::auth::Step::Ptr(
+ new projt::minecraft::auth::XboxSecurityTokenStep(m_credentials,
+ projt::minecraft::auth::XstsTarget::XboxLive)));
+
+ // Step 4: Xbox XSTS Token for Minecraft services
+ m_steps.append(projt::minecraft::auth::Step::Ptr(
+ new projt::minecraft::auth::XboxSecurityTokenStep(m_credentials,
+ projt::minecraft::auth::XstsTarget::MinecraftServices)));
+
+ // Step 5: Minecraft Services Login (get access token)
+ m_steps.append(
+ projt::minecraft::auth::Step::Ptr(new projt::minecraft::auth::MinecraftServicesLoginStep(m_credentials)));
+
+ // Step 6: Xbox Profile (optional, for display - gamertag extraction)
+ m_steps.append(projt::minecraft::auth::Step::Ptr(new projt::minecraft::auth::XboxProfileFetchStep(m_credentials)));
+
+ // Step 7: Game Entitlements
+ m_steps.append(projt::minecraft::auth::Step::Ptr(new projt::minecraft::auth::GameEntitlementsStep(m_credentials)));
+
+ // Step 8: Minecraft Profile
+ m_steps.append(
+ projt::minecraft::auth::Step::Ptr(new projt::minecraft::auth::MinecraftProfileFetchStep(m_credentials)));
+
+ // Step 9: Skin Download
+ m_steps.append(projt::minecraft::auth::Step::Ptr(new projt::minecraft::auth::SkinDownloadStep(m_credentials)));
+
+ qDebug() << "AuthFlow: Built pipeline with" << m_steps.size() << "steps";
+ return true;
+}
+
+void AuthFlow::executeTask()
+{
+ // Handle offline accounts - they don't need authentication
+ if (m_legacyData->type == AccountType::Offline)
+ {
+ qDebug() << "AuthFlow: Offline account - no authentication required, succeeding immediately";
+ if (m_legacyData)
+ {
+ m_legacyData->accountState = AccountState::Online;
+ }
+ updateState(AccountTaskState::STATE_SUCCEEDED, tr("Offline account ready"));
+ return;
+ }
+
+ // Early fail for invalid pipeline (non-offline accounts)
+ if (!m_pipelineValid)
+ {
+ failWithState(AccountTaskState::STATE_FAILED_HARD,
+ tr("Failed to build authentication pipeline for this account type"));
+ return;
+ }
+
+ // Sanity check: empty pipeline should not succeed silently
+ if (m_steps.isEmpty())
+ {
+ qWarning() << "AuthFlow: Pipeline is empty after successful build - this is a bug";
+ failWithState(AccountTaskState::STATE_FAILED_HARD, tr("Authentication pipeline is empty (internal error)"));
+ return;
+ }
+
+ updateState(AccountTaskState::STATE_WORKING, tr("Initializing"));
+ executeNextStep();
+}
+
+void AuthFlow::executeNextStep()
+{
+ // Check abort flag before starting new step
+ if (m_aborted)
+ {
+ qDebug() << "AuthFlow: Skipping next step - flow was aborted";
+ return;
+ }
+
+ if (!Task::isRunning())
+ {
+ return;
+ }
+
+ if (m_steps.isEmpty())
+ {
+ m_currentStep.reset();
+ succeed();
+ return;
+ }
+
+ m_currentStep = m_steps.front();
+ m_steps.pop_front();
+
+ qDebug() << "AuthFlow:" << m_currentStep->description();
+
+ connect(m_currentStep.get(), &projt::minecraft::auth::Step::completed, this, &AuthFlow::onStepCompleted);
+
+ m_currentStep->execute();
+}
+
+void AuthFlow::onStepCompleted(projt::minecraft::auth::StepResult result, QString message)
+{
+ // Map step result to flow state
+ // Note: StepResult::Succeeded means "step succeeded, continue flow"
+ // The flow itself only succeeds when all steps complete (pipeline empty)
+ const auto flowState = stepResultToFlowState(result);
+
+ if (updateState(flowState, message))
+ {
+ executeNextStep();
+ }
+}
+
+void AuthFlow::succeed()
+{
+ // Sync new credentials back to legacy AccountData
+ if (m_legacyData)
+ {
+ m_legacyData->msaClientID = m_credentials.msaClientId;
+ m_legacyData->msaToken.token = m_credentials.msaToken.accessToken;
+ m_legacyData->msaToken.refresh_token = m_credentials.msaToken.refreshToken;
+ m_legacyData->msaToken.issueInstant = m_credentials.msaToken.issuedAt;
+ m_legacyData->msaToken.notAfter = m_credentials.msaToken.expiresAt;
+ m_legacyData->msaToken.extra = m_credentials.msaToken.metadata;
+ m_legacyData->msaToken.validity = toValidity(m_credentials.msaToken.validity);
+
+ m_legacyData->userToken.token = m_credentials.xboxUserToken.accessToken;
+ m_legacyData->userToken.issueInstant = m_credentials.xboxUserToken.issuedAt;
+ m_legacyData->userToken.notAfter = m_credentials.xboxUserToken.expiresAt;
+ m_legacyData->userToken.extra = m_credentials.xboxUserToken.metadata;
+ m_legacyData->userToken.validity = toValidity(m_credentials.xboxUserToken.validity);
+
+ // xboxApiToken receives gamertag from XboxProfileFetchStep via xboxServiceToken.metadata
+ m_legacyData->xboxApiToken.token = m_credentials.xboxServiceToken.accessToken;
+ m_legacyData->xboxApiToken.issueInstant = m_credentials.xboxServiceToken.issuedAt;
+ m_legacyData->xboxApiToken.notAfter = m_credentials.xboxServiceToken.expiresAt;
+ m_legacyData->xboxApiToken.extra = m_credentials.xboxServiceToken.metadata;
+ m_legacyData->xboxApiToken.validity = toValidity(m_credentials.xboxServiceToken.validity);
+
+ m_legacyData->mojangservicesToken.token = m_credentials.minecraftServicesToken.accessToken;
+ m_legacyData->mojangservicesToken.issueInstant = m_credentials.minecraftServicesToken.issuedAt;
+ m_legacyData->mojangservicesToken.notAfter = m_credentials.minecraftServicesToken.expiresAt;
+ m_legacyData->mojangservicesToken.extra = m_credentials.minecraftServicesToken.metadata;
+ m_legacyData->mojangservicesToken.validity = toValidity(m_credentials.minecraftServicesToken.validity);
+
+ m_legacyData->yggdrasilToken.token = m_credentials.minecraftAccessToken.accessToken;
+ m_legacyData->yggdrasilToken.issueInstant = m_credentials.minecraftAccessToken.issuedAt;
+ m_legacyData->yggdrasilToken.notAfter = m_credentials.minecraftAccessToken.expiresAt;
+ m_legacyData->yggdrasilToken.validity = toValidity(m_credentials.minecraftAccessToken.validity);
+
+ m_legacyData->minecraftProfile.id = m_credentials.profile.id;
+ m_legacyData->minecraftProfile.name = m_credentials.profile.name;
+ m_legacyData->minecraftProfile.skin.id = m_credentials.profile.skin.id;
+ m_legacyData->minecraftProfile.skin.url = m_credentials.profile.skin.url;
+ m_legacyData->minecraftProfile.skin.variant = m_credentials.profile.skin.variant;
+ m_legacyData->minecraftProfile.skin.data = m_credentials.profile.skin.imageData;
+ m_legacyData->minecraftProfile.validity = toValidity(m_credentials.profile.validity);
+ m_legacyData->minecraftProfile.currentCape = m_credentials.profile.activeCapeId;
+
+ // Sync capes
+ m_legacyData->minecraftProfile.capes.clear();
+ for (auto it = m_credentials.profile.capes.begin(); it != m_credentials.profile.capes.end(); ++it)
+ {
+ const auto& capeIn = it.value();
+ Cape capeOut;
+ capeOut.id = capeIn.id;
+ capeOut.url = capeIn.url;
+ capeOut.alias = capeIn.alias;
+ capeOut.data = capeIn.imageData;
+ m_legacyData->minecraftProfile.capes.insert(capeIn.id, capeOut);
+ }
+
+ m_legacyData->minecraftEntitlement.ownsMinecraft = m_credentials.entitlements.ownsMinecraft;
+ m_legacyData->minecraftEntitlement.canPlayMinecraft = m_credentials.entitlements.canPlayMinecraft;
+ m_legacyData->minecraftEntitlement.validity = toValidity(m_credentials.entitlements.validity);
+
+ m_legacyData->validity_ = Validity::Certain;
+ }
+
+ updateState(AccountTaskState::STATE_SUCCEEDED, tr("Finished all authentication steps"));
+}
+
+void AuthFlow::failWithState(AccountTaskState state, const QString& reason)
+{
+ if (m_legacyData)
+ {
+ m_legacyData->errorString = reason;
+ }
+ updateState(state, reason);
+}
+
+bool AuthFlow::updateState(AccountTaskState newState, const QString& reason)
+{
+ m_taskState = newState;
+ setDetails(reason);
+
+ switch (newState)
+ {
+ case AccountTaskState::STATE_CREATED:
+ setStatus(tr("Waiting..."));
+ if (m_legacyData)
+ {
+ m_legacyData->errorString.clear();
+ }
+ return true;
+
+ case AccountTaskState::STATE_WORKING:
+ setStatus(m_currentStep ? m_currentStep->description() : tr("Working..."));
+ if (m_legacyData)
+ {
+ m_legacyData->accountState = AccountState::Working;
+ }
+ return true;
+
+ case AccountTaskState::STATE_SUCCEEDED:
+ setStatus(tr("Authentication task succeeded."));
+ if (m_legacyData)
+ {
+ m_legacyData->accountState = AccountState::Online;
+ }
+ emitSucceeded();
+ return false;
+
+ case AccountTaskState::STATE_OFFLINE:
+ setStatus(tr("Failed to contact the authentication server."));
+ if (m_legacyData)
+ {
+ m_legacyData->errorString = reason;
+ m_legacyData->accountState = AccountState::Offline;
+ }
+ emitFailed(reason);
+ return false;
+
+ case AccountTaskState::STATE_DISABLED:
+ setStatus(tr("Client ID has changed. New session needs to be created."));
+ if (m_legacyData)
+ {
+ m_legacyData->errorString = reason;
+ m_legacyData->accountState = AccountState::Disabled;
+ }
+ emitFailed(reason);
+ return false;
+
+ case AccountTaskState::STATE_FAILED_SOFT:
+ setStatus(tr("Encountered an error during authentication."));
+ if (m_legacyData)
+ {
+ m_legacyData->errorString = reason;
+ m_legacyData->accountState = AccountState::Errored;
+ }
+ emitFailed(reason);
+ return false;
+
+ case AccountTaskState::STATE_FAILED_HARD:
+ setStatus(tr("Failed to authenticate. The session has expired."));
+ if (m_legacyData)
+ {
+ m_legacyData->errorString = reason;
+ m_legacyData->accountState = AccountState::Expired;
+ }
+ emitFailed(reason);
+ return false;
+
+ case AccountTaskState::STATE_FAILED_GONE:
+ setStatus(tr("Failed to authenticate. The account no longer exists."));
+ if (m_legacyData)
+ {
+ m_legacyData->errorString = reason;
+ m_legacyData->accountState = AccountState::Gone;
+ }
+ emitFailed(reason);
+ return false;
+
+ default:
+ setStatus(tr("..."));
+ const QString error = tr("Unknown account task state: %1").arg(static_cast<int>(newState));
+ if (m_legacyData)
+ {
+ m_legacyData->accountState = AccountState::Errored;
+ }
+ emitFailed(error);
+ return false;
+ }
+}
+
+AccountTaskState AuthFlow::stepResultToFlowState(projt::minecraft::auth::StepResult result) noexcept
+{
+ // StepResult::Continue and StepResult::Succeeded both mean "step completed successfully"
+ // The distinction is semantic: Continue hints more steps may follow, Succeeded suggests finality.
+ // At the flow level, both translate to STATE_WORKING until the pipeline is exhausted.
+ //
+ // Future: If we add optional/best-effort steps, we may want a StepResult::Skipped that
+ // also maps to STATE_WORKING but logs differently.
+
+ switch (result)
+ {
+ case projt::minecraft::auth::StepResult::Continue:
+ case projt::minecraft::auth::StepResult::Succeeded: return AccountTaskState::STATE_WORKING;
+
+ case projt::minecraft::auth::StepResult::Offline: return AccountTaskState::STATE_OFFLINE;
+
+ case projt::minecraft::auth::StepResult::SoftFailure: return AccountTaskState::STATE_FAILED_SOFT;
+
+ case projt::minecraft::auth::StepResult::HardFailure: return AccountTaskState::STATE_FAILED_HARD;
+
+ case projt::minecraft::auth::StepResult::Disabled: return AccountTaskState::STATE_DISABLED;
+
+ case projt::minecraft::auth::StepResult::Gone: return AccountTaskState::STATE_FAILED_GONE;
+ }
+
+ return AccountTaskState::STATE_FAILED_HARD;
+}
+
+Validity AuthFlow::toValidity(projt::minecraft::auth::TokenValidity validity) noexcept
+{
+ switch (validity)
+ {
+ case projt::minecraft::auth::TokenValidity::None: return Validity::None;
+ case projt::minecraft::auth::TokenValidity::Assumed: return Validity::Assumed;
+ case projt::minecraft::auth::TokenValidity::Certain: return Validity::Certain;
+ }
+ return Validity::None;
+}
+
+bool AuthFlow::abort()
+{
+ // Set abort flag to prevent new steps from starting
+ m_aborted = true;
+
+ qDebug() << "AuthFlow: Abort requested";
+
+ // Cancel current step BEFORE emitting aborted (to prevent use-after-free)
+ // The emitAborted() signal may cause this object to be destroyed
+ if (m_currentStep)
+ {
+ // Disconnect to prevent callbacks after abort
+ disconnect(m_currentStep.get(), nullptr, this, nullptr);
+ m_currentStep->cancel();
+ m_currentStep.reset();
+ }
+
+ // Clear remaining steps
+ m_steps.clear();
+
+ emitAborted();
+
+ return true;
+} \ No newline at end of file
diff --git a/archived/projt-launcher/launcher/minecraft/auth/AuthFlow.hpp b/archived/projt-launcher/launcher/minecraft/auth/AuthFlow.hpp
new file mode 100644
index 0000000000..86fac44286
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/AuthFlow.hpp
@@ -0,0 +1,107 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <QList>
+#include <QNetworkReply>
+#include <QObject>
+#include <QUrl>
+
+#include "minecraft/auth/AccountData.hpp"
+#include "minecraft/auth/steps/Step.hpp"
+#include "minecraft/auth/steps/Credentials.hpp"
+#include "tasks/Task.h"
+
+class AuthFlow : public Task
+{
+ Q_OBJECT
+
+ public:
+ /**
+ * Authentication action to perform.
+ */
+ enum class Action
+ {
+ Refresh, ///< Silent token refresh
+ Login, ///< Interactive browser login
+ DeviceCode ///< Device code flow
+ };
+
+ explicit AuthFlow(AccountData* data, Action action = Action::Refresh);
+ ~AuthFlow() override = default;
+
+ void executeTask() override;
+
+ [[nodiscard]] AccountTaskState taskState() const noexcept
+ {
+ return m_taskState;
+ }
+
+ public slots:
+ bool abort() override;
+
+ signals:
+ /**
+ * Emitted when browser authorization is required.
+ */
+ void authorizeWithBrowser(const QUrl& url);
+
+ /**
+ * Emitted when device code authorization is required.
+ */
+ void authorizeWithBrowserWithExtra(QString url, QString code, int expiresIn);
+
+ private slots:
+ void onStepCompleted(projt::minecraft::auth::StepResult result, QString message);
+
+ private:
+ /**
+ * Build the authentication pipeline based on account type and action.
+ * @return true if pipeline was successfully built, false on configuration error
+ */
+ [[nodiscard]] bool buildPipeline(Action action);
+
+ void executeNextStep();
+ void succeed();
+ void failWithState(AccountTaskState state, const QString& reason);
+ bool updateState(AccountTaskState newState, const QString& reason = QString());
+
+ // Convert new StepResult to intermediate flow state (not final AccountTaskState)
+ [[nodiscard]] static AccountTaskState stepResultToFlowState(projt::minecraft::auth::StepResult result) noexcept;
+
+ // Convert new TokenValidity to legacy Validity
+ [[nodiscard]] static Validity toValidity(projt::minecraft::auth::TokenValidity validity) noexcept;
+
+ AccountTaskState m_taskState = AccountTaskState::STATE_CREATED;
+ QList<projt::minecraft::auth::Step::Ptr> m_steps;
+ projt::minecraft::auth::Step::Ptr m_currentStep;
+
+ // Flow control
+ bool m_aborted = false;
+ bool m_pipelineValid = false;
+
+ // Legacy AccountData for compatibility with existing consumers
+ AccountData* m_legacyData = nullptr;
+
+ // New credentials structure (populated during auth, synced to legacy at end)
+ projt::minecraft::auth::Credentials m_credentials;
+};
diff --git a/archived/projt-launcher/launcher/minecraft/auth/AuthSession.cpp b/archived/projt-launcher/launcher/minecraft/auth/AuthSession.cpp
new file mode 100644
index 0000000000..cedb2e7579
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/AuthSession.cpp
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+#include "AuthSession.hpp"
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#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)
+{
+ session = "-";
+ access_token = "0";
+ player_name = offline_playername;
+ launchMode = LaunchMode::Offline;
+ wants_online = false;
+ demo = false;
+ status = PlayableOffline;
+ return true;
+}
+
+void AuthSession::MakeDemo(QString name, QString u)
+{
+ launchMode = LaunchMode::Demo;
+ wants_online = false;
+ demo = true;
+ uuid = u;
+ session = "-";
+ access_token = "0";
+ player_name = name;
+ status = PlayableOnline; // needs online to download the assets
+};
diff --git a/archived/projt-launcher/launcher/minecraft/auth/AuthSession.hpp b/archived/projt-launcher/launcher/minecraft/auth/AuthSession.hpp
new file mode 100644
index 0000000000..10092a0e91
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/AuthSession.hpp
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+#pragma once
+
+#include <QString>
+#include <memory>
+
+#include "LaunchMode.h"
+
+class MinecraftAccount;
+
+struct AuthSession
+{
+ bool MakeOffline(QString offline_playername);
+ void MakeDemo(QString name, QString uuid);
+
+ QString serializeUserProperties();
+
+ enum Status
+ {
+ Undetermined,
+ RequiresOAuth,
+ RequiresPassword,
+ RequiresProfileSetup,
+ PlayableOffline,
+ PlayableOnline,
+ GoneOrMigrated
+ } status = Undetermined;
+
+ // 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;
+ // The resolved launch mode for this session.
+ LaunchMode launchMode = LaunchMode::Normal;
+ // 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;
+};
+
+using AuthSessionPtr = std::shared_ptr<AuthSession>;
diff --git a/archived/projt-launcher/launcher/minecraft/auth/MinecraftAccount.cpp b/archived/projt-launcher/launcher/minecraft/auth/MinecraftAccount.cpp
new file mode 100644
index 0000000000..5435b62809
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/MinecraftAccount.cpp
@@ -0,0 +1,374 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * === Upstream License Block (Do Not Modify) ==============================
+ *
+ * Prism Launcher - Minecraft Launcher
+ * Copyright (C) 2022 Sefa Eyeoglu <contact@scrumplex.net>
+ *
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * 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.hpp"
+
+#include <QColor>
+#include <QCryptographicHash>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QStringList>
+#include <QUuid>
+
+#include <QDebug>
+
+#include <QPainter>
+
+#include "minecraft/auth/AccountData.hpp"
+#include "minecraft/auth/AuthFlow.hpp"
+
+MinecraftAccount::MinecraftAccount(QObject* parent) : QObject(parent)
+{
+ data.internalId = QUuid::createUuid().toString(QUuid::Id128);
+}
+
+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;
+}
+
+MinecraftAccountPtr MinecraftAccount::createOffline(const QString& username)
+{
+ auto account = makeShared<MinecraftAccount>();
+ account->data.type = AccountType::Offline;
+ account->data.yggdrasilToken.token = "0";
+ account->data.yggdrasilToken.validity = Validity::Certain;
+ account->data.yggdrasilToken.issueInstant = QDateTime::currentDateTimeUtc();
+ account->data.yggdrasilToken.extra["userName"] = username;
+ account->data.yggdrasilToken.extra["clientToken"] = QUuid::createUuid().toString(QUuid::Id128);
+ account->data.minecraftProfile.id = uuidFromUsername(username).toString(QUuid::Id128);
+ account->data.minecraftProfile.name = username;
+ account->data.minecraftProfile.validity = Validity::Certain;
+ 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);
+ skin.fill(QColorConstants::Transparent);
+ 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<AuthFlow> MinecraftAccount::login(bool useDeviceCode)
+{
+ Q_ASSERT(m_currentTask.get() == nullptr);
+
+ m_currentTask.reset(new AuthFlow(&data, useDeviceCode ? AuthFlow::Action::DeviceCode : AuthFlow::Action::Login));
+ connect(m_currentTask.get(), &Task::succeeded, this, &MinecraftAccount::authSucceeded);
+ connect(m_currentTask.get(), &Task::failed, this, &MinecraftAccount::authFailed);
+ connect(m_currentTask.get(), &Task::aborted, this, [this] { authFailed(tr("Aborted")); });
+ emit activityChanged(true);
+ return m_currentTask;
+}
+
+shared_qobject_ptr<AuthFlow> MinecraftAccount::refresh()
+{
+ if (m_currentTask)
+ {
+ return m_currentTask;
+ }
+
+ m_currentTask.reset(new AuthFlow(&data, AuthFlow::Action::Refresh));
+
+ connect(m_currentTask.get(), &Task::succeeded, this, &MinecraftAccount::authSucceeded);
+ connect(m_currentTask.get(), &Task::failed, this, &MinecraftAccount::authFailed);
+ connect(m_currentTask.get(), &Task::aborted, this, [this] { authFailed(tr("Aborted")); });
+ emit activityChanged(true);
+ return m_currentTask;
+}
+
+shared_qobject_ptr<AuthFlow> MinecraftAccount::currentTask()
+{
+ return m_currentTask;
+}
+
+void MinecraftAccount::authSucceeded()
+{
+ m_currentTask.reset();
+ emit authenticationSucceeded();
+ emit changed();
+ emit activityChanged(false);
+}
+
+void MinecraftAccount::authFailed(QString reason)
+{
+ auto taskState = m_currentTask->taskState();
+ emit authenticationFailed(reason, taskState);
+
+ switch (taskState)
+ {
+ case AccountTaskState::STATE_OFFLINE:
+ case AccountTaskState::STATE_DISABLED:
+ {
+ // NOTE: user will need to fix this themselves.
+ }
+ case AccountTaskState::STATE_FAILED_SOFT:
+ {
+ // NOTE: this doesn't do much. There was an error of some sort.
+ }
+ break;
+ case AccountTaskState::STATE_FAILED_HARD:
+ {
+ if (accountType() == AccountType::MSA)
+ {
+ data.msaToken.token = QString();
+ data.msaToken.refresh_token = QString();
+ data.msaToken.validity = Validity::None;
+ data.validity_ = Validity::None;
+ }
+ else
+ {
+ data.yggdrasilToken.token = QString();
+ data.yggdrasilToken.validity = Validity::None;
+ data.validity_ = Validity::None;
+ }
+ emit validityChanged(Validity::None);
+ emit changed();
+ }
+ break;
+ case AccountTaskState::STATE_FAILED_GONE:
+ {
+ data.validity_ = Validity::None;
+ emit validityChanged(Validity::None);
+ emit changed();
+ }
+ break;
+ case AccountTaskState::STATE_WORKING:
+ {
+ data.accountState = AccountState::Unchecked;
+ emit accountStateChanged(AccountState::Unchecked);
+ }
+ break;
+ case AccountTaskState::STATE_CREATED:
+ case AccountTaskState::STATE_SUCCEEDED:
+ {
+ // Not reachable here, as they are not failures.
+ }
+ }
+ m_currentTask.reset();
+ emit activityChanged(false);
+ emit authenticationError(reason);
+}
+
+QString MinecraftAccount::displayName() const
+{
+ const QList validStates{ AccountState::Unchecked, AccountState::Working, AccountState::Offline, AccountState::Online };
+ if (!validStates.contains(accountState()))
+ {
+ return QString("âš  %1").arg(profileName());
+ }
+ return profileName();
+}
+
+bool MinecraftAccount::isActive() const
+{
+ return !m_currentTask.isNull();
+}
+
+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 Validity::Certain:
+ {
+ break;
+ }
+ case Validity::None:
+ {
+ return false;
+ }
+ case Validity::Assumed:
+ {
+ return true;
+ }
+ }
+ auto now = QDateTime::currentDateTimeUtc();
+ auto issuedTimestamp = data.yggdrasilToken.issueInstant;
+ auto expiresTimestamp = data.yggdrasilToken.notAfter;
+
+ if (!expiresTimestamp.isValid())
+ {
+ expiresTimestamp = issuedTimestamp.addSecs(24 * 3600);
+ }
+ if (now.secsTo(expiresTimestamp) < (12 * 3600))
+ {
+ return true;
+ }
+ return false;
+}
+
+void MinecraftAccount::fillSession(AuthSessionPtr session)
+{
+ session->wants_online = session->launchMode != LaunchMode::Offline;
+ session->demo = session->launchMode == LaunchMode::Demo;
+
+ if (ownsMinecraft() && !hasProfile())
+ {
+ session->status = AuthSession::RequiresProfileSetup;
+ }
+ else
+ {
+ if (session->launchMode == LaunchMode::Offline)
+ {
+ session->status = AuthSession::PlayableOffline;
+ }
+ else
+ {
+ session->status = AuthSession::PlayableOnline;
+ }
+ }
+
+ // volatile auth token
+ session->access_token = data.accessToken();
+ // profile name
+ session->player_name = data.profileName();
+ // profile ID
+ session->uuid = data.profileId();
+ if (session->uuid.isEmpty())
+ session->uuid = uuidFromUsername(session->player_name).toString(QUuid::Id128);
+ // '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();
+ // Using internalId for account identification (profile may not be set for new accounts)
+ qWarning() << "Account" << data.internalId << "(" << data.profileName() << ") is no longer in use.";
+ }
+}
+
+void MinecraftAccount::incrementUses()
+{
+ bool wasInUse = isInUse();
+ Usable::incrementUses();
+ if (!wasInUse)
+ {
+ emit changed();
+ // Using internalId for account identification (profile may not be set for new accounts)
+ qWarning() << "Account" << data.internalId << "(" << data.profileName() << ") is now in use.";
+ }
+}
+
+QUuid MinecraftAccount::uuidFromUsername(QString username)
+{
+ auto input = QString("OfflinePlayer:%1").arg(username).toUtf8();
+
+ // basically a reimplementation of Java's UUID#nameUUIDFromBytes
+ QByteArray digest = QCryptographicHash::hash(input, QCryptographicHash::Md5);
+
+ auto bOr = [](QByteArray& array, qsizetype index, uint8_t value) { array[index] |= value; };
+ auto bAnd = [](QByteArray& array, qsizetype index, uint8_t value) { array[index] &= value; };
+ bAnd(digest, 6, 0x0f); // clear version
+ bOr(digest, 6, 0x30); // set to version 3
+ bAnd(digest, 8, 0x3f); // clear variant
+ bOr(digest, 8, 0x80); // set to IETF variant
+
+ return QUuid::fromRfc4122(digest);
+}
diff --git a/archived/projt-launcher/launcher/minecraft/auth/MinecraftAccount.hpp b/archived/projt-launcher/launcher/minecraft/auth/MinecraftAccount.hpp
new file mode 100644
index 0000000000..8af81e0a26
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/MinecraftAccount.hpp
@@ -0,0 +1,243 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * === Upstream License Block (Do Not Modify) ==============================
+ *
+ * Prism Launcher - Minecraft Launcher
+ * Copyright (C) 2022 Sefa Eyeoglu <contact@scrumplex.net>
+ *
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * 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 <QJsonObject>
+#include <QList>
+#include <QMap>
+#include <QObject>
+#include <QPair>
+#include <QPixmap>
+#include <QString>
+
+#include "AccountData.hpp"
+#include "AuthSession.hpp"
+#include "QObjectPtr.h"
+#include "Usable.h"
+#include "minecraft/auth/AuthFlow.hpp"
+
+class Task;
+class MinecraftAccount;
+
+using MinecraftAccountPtr = shared_qobject_ptr<MinecraftAccount>;
+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 ProjT Launcher 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 createOffline(const QString& username);
+
+ static MinecraftAccountPtr loadFromJsonV3(const QJsonObject& json);
+
+ static QUuid uuidFromUsername(QString username);
+
+ //! Saves a MinecraftAccount to a JSON object and returns it.
+ QJsonObject saveToJson() const;
+
+ public: /* manipulation */
+ shared_qobject_ptr<AuthFlow> login(bool useDeviceCode = false);
+
+ shared_qobject_ptr<AuthFlow> refresh();
+
+ shared_qobject_ptr<AuthFlow> 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();
+ }
+
+ QString displayName() const;
+
+ bool isActive() const;
+
+ AccountType accountType() const noexcept
+ {
+ return data.type;
+ }
+
+ bool ownsMinecraft() const
+ {
+ return data.type != AccountType::Offline && data.minecraftEntitlement.ownsMinecraft;
+ }
+
+ bool hasProfile() const
+ {
+ return data.profileId().size() != 0;
+ }
+
+ QString typeString() const
+ {
+ switch (data.type)
+ {
+ case AccountType::MSA:
+ {
+ return "msa";
+ }
+ break;
+ case AccountType::Offline:
+ {
+ return "offline";
+ }
+ break;
+ default:
+ {
+ return "unknown";
+ }
+ }
+ }
+
+ 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);
+
+ // Specific signals for different state changes
+ void accountStateChanged(AccountState newState);
+ void authenticationSucceeded();
+ void authenticationFailed(QString reason, AccountTaskState taskState);
+ void profileUpdated();
+ void validityChanged(Validity newValidity);
+ /// Emitted when an authentication error occurs
+ void authenticationError(QString errorMessage);
+
+ protected: /* variables */
+ AccountData data;
+
+ // current task we are executing here
+ shared_qobject_ptr<AuthFlow> m_currentTask;
+
+ protected: /* methods */
+ void incrementUses() override;
+ void decrementUses() override;
+
+ private slots:
+ void authSucceeded();
+ void authFailed(QString reason);
+};
diff --git a/archived/projt-launcher/launcher/minecraft/auth/Parsers.cpp b/archived/projt-launcher/launcher/minecraft/auth/Parsers.cpp
new file mode 100644
index 0000000000..184de65898
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/Parsers.cpp
@@ -0,0 +1,597 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+#include "Parsers.hpp"
+#include "Json.h"
+#include "minecraft/Logging.h"
+
+#include <QDebug>
+#include <QJsonArray>
+#include <QJsonDocument>
+
+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"
+ }
+ ]
+ }
+ }
+ */
+ // Error responses from Xbox Live are handled in parseXTokenResponse below.
+ // Known error codes:
+ // - 2148916233: Missing Xbox account
+ // - 2148916238: Child account not linked to a family
+ /*
+ {
+ "Identity":"0",
+ "XErr":2148916238,
+ "Message":"",
+ "Redirect":"https://start.ui.xboxlive.com/AddChildToFamily"
+ }
+ */
+
+ bool parseXTokenResponse(QByteArray& data, Token& output, QString name)
+ {
+ qDebug() << "Parsing" << name << ":";
+ qCDebug(authCredentials()) << data;
+ 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 = Validity::Certain;
+ qDebug() << name << "is valid.";
+ return true;
+ }
+
+ bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output)
+ {
+ qDebug() << "Parsing Minecraft profile...";
+ qCDebug(authCredentials()) << data;
+
+ 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;
+ }
+ skinOut.url.replace("http://textures.minecraft.net", "https://textures.minecraft.net");
+ 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;
+ }
+ capeOut.url.replace("http://textures.minecraft.net", "https://textures.minecraft.net");
+ if (!getString(capeObj.value("alias"), capeOut.alias))
+ {
+ continue;
+ }
+
+ output.capes[capeOut.id] = capeOut;
+ }
+ output.currentCape = currentCape;
+ output.validity = Validity::Certain;
+ return true;
+ }
+
+ namespace
+ {
+ // these skin URLs are for the MHF_Steve and MHF_Alex accounts (made by a Mojang employee)
+ // they are needed because the session server doesn't return skin urls for default skins
+ static const QString SKIN_URL_STEVE =
+ "https://textures.minecraft.net/texture/1a4af718455d4aab528e7a61f86fa25e6a369d1768dcb13f7df319a713eb810b";
+ static const QString SKIN_URL_ALEX =
+ "https://textures.minecraft.net/texture/83cee5ca6afcdb171285aa00e8049c297b2dbeba0efb8ff970a5677a1b644032";
+
+ bool isDefaultModelSteve(QString uuid)
+ {
+ // need to calculate *Java* hashCode of UUID
+ // if number is even, skin/model is steve, otherwise it is alex
+
+ // just in case dashes are in the id
+ uuid.remove('-');
+
+ if (uuid.size() != 32)
+ {
+ return true;
+ }
+
+ // qulonglong is guaranteed to be 64 bits
+ // we need to use unsigned numbers to guarantee truncation below
+ qulonglong most = uuid.left(16).toULongLong(nullptr, 16);
+ qulonglong least = uuid.right(16).toULongLong(nullptr, 16);
+ qulonglong xored = most ^ least;
+ return ((static_cast<quint32>(xored >> 32)) ^ static_cast<quint32>(xored)) % 2 == 0;
+ }
+ } // namespace
+
+ /**
+ Uses session server for skin/cape lookup instead of profile,
+ because locked Mojang accounts cannot access profile endpoint
+ (https://api.minecraftservices.com/minecraft/profile/)
+
+ ref: https://wiki.vg/Mojang_API#UUID_to_Profile_and_Skin.2FCape
+
+ {
+ "id": "<profile identifier>",
+ "name": "<player name>",
+ "properties": [
+ {
+ "name": "textures",
+ "value": "<base64 string>"
+ }
+ ]
+ }
+
+ decoded base64 "value":
+ {
+ "timestamp": <java time in ms>,
+ "profileId": "<profile uuid>",
+ "profileName": "<player name>",
+ "textures": {
+ "SKIN": {
+ "url": "<player skin URL>"
+ },
+ "CAPE": {
+ "url": "<player cape URL>"
+ }
+ }
+ }
+ */
+
+ bool parseMinecraftProfileMojang(QByteArray& data, MinecraftProfile& output)
+ {
+ qDebug() << "Parsing Minecraft profile...";
+ qCDebug(authCredentials()) << data;
+
+ QJsonParseError jsonError;
+ QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
+ if (jsonError.error)
+ {
+ qWarning() << "Failed to parse response as JSON: " << jsonError.errorString();
+ return false;
+ }
+
+ auto obj = Json::requireObject(doc, "mojang minecraft profile");
+ 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 propsArray = obj.value("properties").toArray();
+ QByteArray texturePayload;
+ for (auto p : propsArray)
+ {
+ auto pObj = p.toObject();
+ auto name = pObj.value("name");
+ if (!name.isString() || name.toString() != "textures")
+ {
+ continue;
+ }
+
+ auto value = pObj.value("value");
+ if (value.isString())
+ {
+ texturePayload =
+ QByteArray::fromBase64(value.toString().toUtf8(), QByteArray::AbortOnBase64DecodingErrors);
+ }
+
+ if (!texturePayload.isEmpty())
+ {
+ break;
+ }
+ }
+
+ if (texturePayload.isNull())
+ {
+ qWarning() << "No texture payload data";
+ return false;
+ }
+
+ doc = QJsonDocument::fromJson(texturePayload, &jsonError);
+ if (jsonError.error)
+ {
+ qWarning() << "Failed to parse response as JSON: " << jsonError.errorString();
+ return false;
+ }
+
+ obj = Json::requireObject(doc, "session texture payload");
+ auto textures = obj.value("textures");
+ if (!textures.isObject())
+ {
+ qWarning() << "No textures array in response";
+ return false;
+ }
+
+ Skin skinOut;
+ // fill in default skin info ourselves, as this endpoint doesn't provide it
+ bool steve = isDefaultModelSteve(output.id);
+ skinOut.variant = steve ? "CLASSIC" : "SLIM";
+ skinOut.url = steve ? SKIN_URL_STEVE : SKIN_URL_ALEX;
+ // sadly we can't figure this out, but I don't think it really matters...
+ skinOut.id = "00000000-0000-0000-0000-000000000000";
+ Cape capeOut;
+ auto tObj = textures.toObject();
+ for (auto idx = tObj.constBegin(); idx != tObj.constEnd(); ++idx)
+ {
+ if (idx->isObject())
+ {
+ if (idx.key() == "SKIN")
+ {
+ auto skin = idx->toObject();
+ if (!getString(skin.value("url"), skinOut.url))
+ {
+ qWarning() << "Skin url is not a string";
+ return false;
+ }
+ skinOut.url.replace("http://textures.minecraft.net", "https://textures.minecraft.net");
+
+ auto maybeMeta = skin.find("metadata");
+ if (maybeMeta != skin.end() && maybeMeta->isObject())
+ {
+ auto meta = maybeMeta->toObject();
+ // might not be present
+ getString(meta.value("model"), skinOut.variant);
+ }
+ }
+ else if (idx.key() == "CAPE")
+ {
+ auto cape = idx->toObject();
+ if (!getString(cape.value("url"), capeOut.url))
+ {
+ qWarning() << "Cape url is not a string";
+ return false;
+ }
+ capeOut.url.replace("http://textures.minecraft.net", "https://textures.minecraft.net");
+
+ // we don't know the cape ID as it is not returned from the session server
+ // so just fake it - changing capes is probably locked anyway :(
+ capeOut.alias = "cape";
+ }
+ }
+ }
+
+ output.skin = skinOut;
+ if (capeOut.alias == "cape")
+ {
+ output.capes = QMap<QString, Cape>({ { capeOut.alias, capeOut } });
+ output.currentCape = capeOut.alias;
+ }
+
+ output.validity = Validity::Certain;
+ return true;
+ }
+
+ bool parseMinecraftEntitlements(QByteArray& data, MinecraftEntitlement& output)
+ {
+ qDebug() << "Parsing Minecraft entitlements...";
+ qCDebug(authCredentials()) << data;
+
+ 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 = Validity::Certain;
+ return true;
+ }
+
+ bool parseRolloutResponse(QByteArray& data, bool& result)
+ {
+ qDebug() << "Parsing Rollout response...";
+ qCDebug(authCredentials()) << data;
+
+ 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, Token& output)
+ {
+ QJsonParseError jsonError;
+ qDebug() << "Parsing Mojang response...";
+ qCDebug(authCredentials()) << data;
+ 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;
+ }
+
+ // Basic JWT structure validation: JWTs have 3 dot-separated parts (header.payload.signature)
+ QString accessToken;
+ if (!getString(obj.value("access_token"), accessToken))
+ {
+ qWarning() << "access_token is not valid";
+ return false;
+ }
+ auto parts = accessToken.split('.');
+ if (parts.size() != 3)
+ {
+ qWarning() << "access_token is not a valid JWT (expected 3 parts, got" << parts.size() << ")";
+ return false;
+ }
+ output.token = accessToken;
+ output.validity = Validity::Certain;
+ qDebug() << "Mojang response is valid.";
+ return true;
+ }
+
+} // namespace Parsers
diff --git a/archived/projt-launcher/launcher/minecraft/auth/Parsers.hpp b/archived/projt-launcher/launcher/minecraft/auth/Parsers.hpp
new file mode 100644
index 0000000000..577059e636
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/Parsers.hpp
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+#pragma once
+
+#include "AccountData.hpp"
+
+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, Token& output, QString name);
+ bool parseMojangResponse(QByteArray& data, Token& output);
+
+ bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output);
+ bool parseMinecraftProfileMojang(QByteArray& data, MinecraftProfile& output);
+ bool parseMinecraftEntitlements(QByteArray& data, MinecraftEntitlement& output);
+ bool parseRolloutResponse(QByteArray& data, bool& result);
+} // namespace Parsers
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/Credentials.hpp b/archived/projt-launcher/launcher/minecraft/auth/steps/Credentials.hpp
new file mode 100644
index 0000000000..1de4898a67
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/Credentials.hpp
@@ -0,0 +1,233 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <QByteArray>
+#include <QDateTime>
+#include <QMap>
+#include <QString>
+#include <QVariantMap>
+
+namespace projt::minecraft::auth
+{
+
+ /**
+ * Validity state for tokens and data.
+ */
+ enum class TokenValidity
+ {
+ None, ///< Not validated or expired
+ Assumed, ///< Assumed valid (e.g., loaded from disk)
+ Certain ///< Verified valid by server
+ };
+
+ /**
+ * Generic OAuth/authentication token.
+ */
+ struct AuthToken
+ {
+ QDateTime issuedAt; ///< When the token was issued
+ QDateTime expiresAt; ///< When the token expires
+ QString accessToken; ///< The access token value
+ QString refreshToken; ///< OAuth refresh token (if applicable)
+ QVariantMap metadata; ///< Additional token data (e.g., user hash)
+
+ TokenValidity validity = TokenValidity::None;
+ bool persist = true; ///< Whether to save this token to disk
+
+ [[nodiscard]] bool isExpired() const noexcept
+ {
+ return expiresAt.isValid() && QDateTime::currentDateTimeUtc() >= expiresAt;
+ }
+
+ [[nodiscard]] bool hasRefreshToken() const noexcept
+ {
+ return !refreshToken.isEmpty();
+ }
+ };
+
+ /**
+ * Minecraft player skin data.
+ */
+ struct PlayerSkin
+ {
+ QString id;
+ QString url;
+ QString variant; ///< "CLASSIC" or "SLIM"
+ QByteArray imageData;
+
+ [[nodiscard]] bool isEmpty() const noexcept
+ {
+ return id.isEmpty();
+ }
+ };
+
+ /**
+ * Minecraft player cape data.
+ */
+ struct PlayerCape
+ {
+ QString id;
+ QString url;
+ QString alias;
+ QByteArray imageData;
+
+ [[nodiscard]] bool isEmpty() const noexcept
+ {
+ return id.isEmpty();
+ }
+ };
+
+ /**
+ * Minecraft game entitlements (ownership info).
+ */
+ struct GameEntitlements
+ {
+ bool ownsMinecraft = false;
+ bool canPlayMinecraft = false;
+ TokenValidity validity = TokenValidity::None;
+
+ [[nodiscard]] bool isValid() const noexcept
+ {
+ return validity != TokenValidity::None;
+ }
+ };
+
+ /**
+ * Minecraft Java Edition profile.
+ */
+ struct MinecraftJavaProfile
+ {
+ QString id; ///< UUID without dashes
+ QString name; ///< Player name (gamertag)
+ PlayerSkin skin;
+ QString activeCapeId;
+ QMap<QString, PlayerCape> capes;
+ TokenValidity validity = TokenValidity::None;
+
+ [[nodiscard]] bool hasProfile() const noexcept
+ {
+ return !id.isEmpty();
+ }
+ [[nodiscard]] bool hasName() const noexcept
+ {
+ return !name.isEmpty();
+ }
+ };
+
+ /**
+ * Account type enumeration.
+ */
+ enum class AccountKind
+ {
+ Microsoft, ///< Microsoft/Xbox Live authenticated
+ Offline ///< Offline mode (no authentication)
+ };
+
+ /**
+ * Account status enumeration.
+ */
+ enum class AccountStatus
+ {
+ Unchecked, ///< Not yet validated
+ Offline, ///< Network unavailable
+ Working, ///< Auth in progress
+ Online, ///< Fully authenticated
+ Disabled, ///< Disabled (e.g., client ID mismatch)
+ Error, ///< Error state
+ Expired, ///< Tokens expired, needs refresh
+ Gone ///< Account no longer exists
+ };
+
+ /**
+ * Complete authentication credentials for a Minecraft account.
+ *
+ * This structure holds all tokens and profile information needed to
+ * authenticate and play Minecraft. It is passed by reference to Step
+ * implementations which populate fields as authentication progresses.
+ */
+ struct Credentials
+ {
+ // === Account identification ===
+ AccountKind kind = AccountKind::Microsoft;
+ QString internalId; ///< Internal account identifier
+ QString msaClientId; ///< Microsoft Application client ID used
+
+ // === Microsoft authentication chain ===
+ AuthToken msaToken; ///< Microsoft OAuth token
+ AuthToken xboxUserToken; ///< XBL user token
+ AuthToken xboxServiceToken; ///< XSTS token for Xbox services
+ AuthToken minecraftServicesToken; ///< XSTS token for Minecraft services
+
+ // === Minecraft authentication ===
+ AuthToken minecraftAccessToken; ///< Yggdrasil-style access token
+ MinecraftJavaProfile profile; ///< Player profile
+ GameEntitlements entitlements; ///< Game ownership
+
+ // === Runtime state (not persisted) ===
+ AccountStatus status = AccountStatus::Unchecked;
+ QString lastError;
+
+ // === Convenience accessors ===
+
+ /**
+ * Display string for this account (gamertag or profile name).
+ */
+ [[nodiscard]] QString displayName() const noexcept
+ {
+ return profile.hasName() ? profile.name : QStringLiteral("(unknown)");
+ }
+
+ /**
+ * Access token to pass to the game.
+ */
+ [[nodiscard]] QString accessToken() const noexcept
+ {
+ return minecraftAccessToken.accessToken;
+ }
+
+ /**
+ * Profile UUID for game launch.
+ */
+ [[nodiscard]] QString profileId() const noexcept
+ {
+ return profile.id;
+ }
+
+ /**
+ * Profile name for game launch.
+ */
+ [[nodiscard]] QString profileName() const noexcept
+ {
+ return profile.name;
+ }
+
+ /**
+ * Xbox user hash (uhs) from token metadata.
+ */
+ [[nodiscard]] QString xboxUserHash() const noexcept
+ {
+ return xboxUserToken.metadata.value(QStringLiteral("uhs")).toString();
+ }
+ };
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/DeviceCodeAuthStep.cpp b/archived/projt-launcher/launcher/minecraft/auth/steps/DeviceCodeAuthStep.cpp
new file mode 100644
index 0000000000..48433e81f6
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/DeviceCodeAuthStep.cpp
@@ -0,0 +1,323 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "DeviceCodeAuthStep.hpp"
+
+#include <QDateTime>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonParseError>
+#include <QUrlQuery>
+
+#include "Application.h"
+#include "Json.h"
+#include "net/RawHeaderProxy.h"
+
+namespace projt::minecraft::auth
+{
+
+ namespace
+ {
+
+ // Device authorization endpoints
+ constexpr auto kDeviceCodeUrl = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
+ constexpr auto kTokenUrl = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
+
+ /**
+ * Parse device code response from Microsoft.
+ */
+ struct DeviceCodeResponse
+ {
+ QString deviceCode;
+ QString userCode;
+ QString verificationUri;
+ int expiresIn = 0;
+ int interval = 5;
+ QString error;
+ QString errorDescription;
+
+ [[nodiscard]] bool isValid() const noexcept
+ {
+ return !deviceCode.isEmpty() && !userCode.isEmpty() && !verificationUri.isEmpty() && expiresIn > 0;
+ }
+ };
+
+ [[nodiscard]] DeviceCodeResponse parseDeviceCodeResponse(const QByteArray& data)
+ {
+ QJsonParseError err;
+ const auto doc = QJsonDocument::fromJson(data, &err);
+ if (err.error != QJsonParseError::NoError || !doc.isObject())
+ {
+ qWarning() << "Failed to parse device code response:" << err.errorString();
+ return {};
+ }
+
+ const auto obj = doc.object();
+ return { Json::ensureString(obj, "device_code"), Json::ensureString(obj, "user_code"),
+ Json::ensureString(obj, "verification_uri"), Json::ensureInteger(obj, "expires_in"),
+ Json::ensureInteger(obj, "interval", 5), Json::ensureString(obj, "error"),
+ Json::ensureString(obj, "error_description") };
+ }
+
+ /**
+ * Parse token response from Microsoft.
+ */
+ struct TokenResponse
+ {
+ QString accessToken;
+ QString tokenType;
+ QString refreshToken;
+ int expiresIn = 0;
+ QString error;
+ QString errorDescription;
+ QVariantMap metadata;
+
+ [[nodiscard]] bool isSuccess() const noexcept
+ {
+ return !accessToken.isEmpty();
+ }
+ [[nodiscard]] bool isPending() const noexcept
+ {
+ return error == QStringLiteral("authorization_pending");
+ }
+ [[nodiscard]] bool needsSlowDown() const noexcept
+ {
+ return error == QStringLiteral("slow_down");
+ }
+ };
+
+ [[nodiscard]] TokenResponse parseTokenResponse(const QByteArray& data)
+ {
+ QJsonParseError err;
+ const auto doc = QJsonDocument::fromJson(data, &err);
+ if (err.error != QJsonParseError::NoError || !doc.isObject())
+ {
+ qWarning() << "Failed to parse token response:" << err.errorString();
+ return {};
+ }
+
+ const auto obj = doc.object();
+ return { Json::ensureString(obj, "access_token"),
+ Json::ensureString(obj, "token_type"),
+ Json::ensureString(obj, "refresh_token"),
+ Json::ensureInteger(obj, "expires_in"),
+ Json::ensureString(obj, "error"),
+ Json::ensureString(obj, "error_description"),
+ obj.toVariantMap() };
+ }
+
+ } // namespace
+
+ DeviceCodeAuthStep::DeviceCodeAuthStep(Credentials& credentials) noexcept
+ : Step(credentials),
+ m_clientId(APPLICATION->getMSAClientID())
+ {
+ m_pollTimer.setTimerType(Qt::VeryCoarseTimer);
+ m_pollTimer.setSingleShot(true);
+ m_expirationTimer.setTimerType(Qt::VeryCoarseTimer);
+ m_expirationTimer.setSingleShot(true);
+
+ connect(&m_expirationTimer, &QTimer::timeout, this, &DeviceCodeAuthStep::cancel);
+ connect(&m_pollTimer, &QTimer::timeout, this, &DeviceCodeAuthStep::pollForCompletion);
+ }
+
+ QString DeviceCodeAuthStep::description() const
+ {
+ return tr("Logging in with Microsoft account (device code).");
+ }
+
+ void DeviceCodeAuthStep::execute()
+ {
+ QUrlQuery query;
+ query.addQueryItem(QStringLiteral("client_id"), m_clientId);
+ query.addQueryItem(QStringLiteral("scope"), QStringLiteral("XboxLive.SignIn XboxLive.offline_access"));
+
+ const auto payload = query.query(QUrl::FullyEncoded).toUtf8();
+ const QUrl url(QString::fromLatin1(kDeviceCodeUrl));
+
+ const auto headers = QList<Net::HeaderPair>{ { "Content-Type", "application/x-www-form-urlencoded" },
+ { "Accept", "application/json" } };
+
+ m_response = std::make_shared<QByteArray>();
+ m_request = Net::Upload::makeByteArray(url, m_response, payload);
+ m_request->addHeaderProxy(new Net::RawHeaderProxy(headers));
+
+ m_task = NetJob::Ptr::create(QStringLiteral("DeviceCodeRequest"), APPLICATION->network());
+ m_task->setAskRetry(false);
+ m_task->addNetAction(m_request);
+
+ connect(m_task.get(), &Task::finished, this, &DeviceCodeAuthStep::onDeviceCodeReceived);
+ m_task->start();
+ }
+
+ void DeviceCodeAuthStep::cancel() noexcept
+ {
+ m_cancelled = true;
+ m_expirationTimer.stop();
+ m_pollTimer.stop();
+
+ if (m_request)
+ {
+ m_request->abort();
+ }
+
+ emit completed(StepResult::HardFailure, tr("Authentication cancelled or timed out."));
+ }
+
+ void DeviceCodeAuthStep::onDeviceCodeReceived()
+ {
+ const auto rsp = parseDeviceCodeResponse(*m_response);
+
+ if (!rsp.error.isEmpty())
+ {
+ const QString msg = rsp.errorDescription.isEmpty() ? rsp.error : rsp.errorDescription;
+ emit completed(StepResult::HardFailure, tr("Device authorization failed: %1").arg(msg));
+ return;
+ }
+
+ if (!m_request->wasSuccessful() || m_request->error() != QNetworkReply::NoError)
+ {
+ emit completed(StepResult::HardFailure, tr("Failed to request device authorization."));
+ return;
+ }
+
+ if (!rsp.isValid())
+ {
+ emit completed(StepResult::HardFailure, tr("Invalid device authorization response."));
+ return;
+ }
+
+ m_deviceCode = rsp.deviceCode;
+ m_pollInterval = rsp.interval > 0 ? rsp.interval : 5;
+
+ // Notify UI to display code
+ emit deviceCodeReady(rsp.verificationUri, rsp.userCode, rsp.expiresIn);
+
+ // Start polling
+ startPolling(m_pollInterval, rsp.expiresIn);
+ }
+
+ void DeviceCodeAuthStep::startPolling(int intervalSecs, int expiresInSecs)
+ {
+ if (m_cancelled)
+ {
+ return;
+ }
+
+ m_expirationTimer.setInterval(expiresInSecs * 1000);
+ m_expirationTimer.start();
+
+ m_pollTimer.setInterval(intervalSecs * 1000);
+ m_pollTimer.start();
+ }
+
+ void DeviceCodeAuthStep::pollForCompletion()
+ {
+ if (m_cancelled)
+ {
+ return;
+ }
+
+ QUrlQuery query;
+ query.addQueryItem(QStringLiteral("client_id"), m_clientId);
+ query.addQueryItem(QStringLiteral("grant_type"),
+ QStringLiteral("urn:ietf:params:oauth:grant-type:device_code"));
+ query.addQueryItem(QStringLiteral("device_code"), m_deviceCode);
+
+ const auto payload = query.query(QUrl::FullyEncoded).toUtf8();
+ const QUrl url(QString::fromLatin1(kTokenUrl));
+
+ const auto headers = QList<Net::HeaderPair>{ { "Content-Type", "application/x-www-form-urlencoded" },
+ { "Accept", "application/json" } };
+
+ m_response = std::make_shared<QByteArray>();
+ m_request = Net::Upload::makeByteArray(url, m_response, payload);
+ m_request->addHeaderProxy(new Net::RawHeaderProxy(headers));
+ m_request->setNetwork(APPLICATION->network());
+
+ connect(m_request.get(), &Task::finished, this, &DeviceCodeAuthStep::onPollResponse);
+ m_request->start();
+ }
+
+ void DeviceCodeAuthStep::onPollResponse()
+ {
+ if (m_cancelled)
+ {
+ return;
+ }
+
+ // Handle timeout - exponential backoff per RFC 8628
+ if (m_request->error() == QNetworkReply::TimeoutError)
+ {
+ m_pollInterval *= 2;
+ m_pollTimer.setInterval(m_pollInterval * 1000);
+ m_pollTimer.start();
+ return;
+ }
+
+ const auto rsp = parseTokenResponse(*m_response);
+
+ // Handle slow_down - increase interval by 5 seconds per RFC 8628
+ if (rsp.needsSlowDown())
+ {
+ m_pollInterval += 5;
+ m_pollTimer.setInterval(m_pollInterval * 1000);
+ m_pollTimer.start();
+ return;
+ }
+
+ // Authorization still pending - keep polling
+ if (rsp.isPending())
+ {
+ m_pollTimer.start();
+ return;
+ }
+
+ // Check for other errors
+ if (!rsp.error.isEmpty())
+ {
+ const QString msg = rsp.errorDescription.isEmpty() ? rsp.error : rsp.errorDescription;
+ emit completed(StepResult::HardFailure, tr("Device authentication failed: %1").arg(msg));
+ return;
+ }
+
+ // Network error - retry
+ if (!m_request->wasSuccessful() || m_request->error() != QNetworkReply::NoError)
+ {
+ m_pollTimer.start();
+ return;
+ }
+
+ // Success!
+ m_expirationTimer.stop();
+
+ m_credentials.msaClientId = m_clientId;
+ m_credentials.msaToken.issuedAt = QDateTime::currentDateTimeUtc();
+ m_credentials.msaToken.expiresAt = QDateTime::currentDateTimeUtc().addSecs(rsp.expiresIn);
+ m_credentials.msaToken.metadata = rsp.metadata;
+ m_credentials.msaToken.refreshToken = rsp.refreshToken;
+ m_credentials.msaToken.accessToken = rsp.accessToken;
+ m_credentials.msaToken.validity = TokenValidity::Certain;
+
+ emit completed(StepResult::Continue, tr("Microsoft authentication successful."));
+ }
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/DeviceCodeAuthStep.hpp b/archived/projt-launcher/launcher/minecraft/auth/steps/DeviceCodeAuthStep.hpp
new file mode 100644
index 0000000000..15d81d2e6b
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/DeviceCodeAuthStep.hpp
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <QTimer>
+#include <memory>
+
+#include "Step.hpp"
+#include "net/NetJob.h"
+#include "net/Upload.h"
+
+namespace projt::minecraft::auth
+{
+
+ /**
+ * Microsoft OAuth 2.0 Device Code Flow step.
+ *
+ * Used for environments where browser-based login is impractical.
+ * Displays a code for the user to enter at a Microsoft URL.
+ *
+ * Flow:
+ * 1. Request device code from Microsoft
+ * 2. Emit deviceCodeReady signal with code and URL
+ * 3. Poll for user completion
+ * 4. On success, populate MSA token in Credentials
+ */
+ class DeviceCodeAuthStep : public Step
+ {
+ Q_OBJECT
+
+ public:
+ explicit DeviceCodeAuthStep(Credentials& credentials) noexcept;
+ ~DeviceCodeAuthStep() noexcept override = default;
+
+ [[nodiscard]] QString description() const override;
+
+ public slots:
+ void execute() override;
+ void cancel() noexcept override;
+
+ private slots:
+ void onDeviceCodeReceived();
+ void pollForCompletion();
+ void onPollResponse();
+
+ private:
+ void startPolling(int intervalSecs, int expiresInSecs);
+
+ QString m_clientId;
+ QString m_deviceCode;
+ int m_pollInterval = 5; // seconds
+ bool m_cancelled = false;
+
+ QTimer m_pollTimer;
+ QTimer m_expirationTimer;
+
+ std::shared_ptr<QByteArray> m_response;
+ Net::Upload::Ptr m_request;
+ NetJob::Ptr m_task;
+ };
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/GameEntitlementsStep.cpp b/archived/projt-launcher/launcher/minecraft/auth/steps/GameEntitlementsStep.cpp
new file mode 100644
index 0000000000..71058bd6ac
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/GameEntitlementsStep.cpp
@@ -0,0 +1,145 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "GameEntitlementsStep.hpp"
+
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonParseError>
+#include <QNetworkRequest>
+#include <QUrl>
+#include <QUuid>
+
+#include "Application.h"
+#include "minecraft/Logging.h"
+#include "net/RawHeaderProxy.h"
+
+namespace projt::minecraft::auth
+{
+
+ namespace
+ {
+
+ constexpr auto kEntitlementsUrl = "https://api.minecraftservices.com/entitlements/license";
+
+ } // namespace
+
+ GameEntitlementsStep::GameEntitlementsStep(Credentials& credentials) noexcept : Step(credentials)
+ {}
+
+ QString GameEntitlementsStep::description() const
+ {
+ return tr("Checking game ownership.");
+ }
+
+ void GameEntitlementsStep::execute()
+ {
+ // Generate unique request ID for validation
+ m_requestId = QUuid::createUuid().toString(QUuid::WithoutBraces);
+
+ QUrl url(QString::fromLatin1(kEntitlementsUrl));
+ url.setQuery(QStringLiteral("requestId=%1").arg(m_requestId));
+
+ const QString authHeader = QStringLiteral("Bearer %1").arg(m_credentials.minecraftAccessToken.accessToken);
+
+ const auto headers = QList<Net::HeaderPair>{ { "Content-Type", "application/json" },
+ { "Accept", "application/json" },
+ { "Authorization", authHeader.toUtf8() } };
+
+ m_response = std::make_shared<QByteArray>();
+ m_request = Net::Download::makeByteArray(url, m_response);
+ m_request->addHeaderProxy(new Net::RawHeaderProxy(headers));
+
+ m_task = NetJob::Ptr::create(QStringLiteral("GameEntitlements"), APPLICATION->network());
+ m_task->setAskRetry(false);
+ m_task->addNetAction(m_request);
+
+ connect(m_task.get(), &Task::finished, this, &GameEntitlementsStep::onRequestCompleted);
+ m_task->start();
+
+ qDebug() << "Checking game entitlements...";
+ }
+
+ void GameEntitlementsStep::onRequestCompleted()
+ {
+ qCDebug(authCredentials()) << *m_response;
+
+ // Entitlements fetch is non-critical - continue even on failure
+ if (!parseEntitlementsResponse(*m_response))
+ {
+ qWarning() << "Failed to parse entitlements response; continuing without entitlements.";
+ }
+
+ emit completed(StepResult::Continue, tr("Got entitlements info."));
+ }
+
+ bool GameEntitlementsStep::parseEntitlementsResponse(const QByteArray& data)
+ {
+ QJsonParseError err;
+ const auto doc = QJsonDocument::fromJson(data, &err);
+ if (err.error != QJsonParseError::NoError || !doc.isObject())
+ {
+ qWarning() << "Failed to parse entitlements:" << err.errorString();
+ return false;
+ }
+
+ const auto obj = doc.object();
+
+ // Validate request ID matches
+ const QString responseRequestId = obj.value(QStringLiteral("requestId")).toString();
+ if (!responseRequestId.isEmpty() && responseRequestId != m_requestId)
+ {
+ qWarning() << "Entitlements request ID mismatch! Expected:" << m_requestId << "Got:" << responseRequestId;
+ }
+
+ // Parse items array for Minecraft entitlements
+ const auto items = obj.value(QStringLiteral("items")).toArray();
+ bool hasMinecraft = false;
+ bool hasGamePass = false;
+
+ for (const auto& itemVal : items)
+ {
+ const auto itemObj = itemVal.toObject();
+ const QString name = itemObj.value(QStringLiteral("name")).toString();
+
+ if (name == QStringLiteral("game_minecraft") || name == QStringLiteral("product_minecraft"))
+ {
+ hasMinecraft = true;
+ }
+ if (name == QStringLiteral("game_minecraft_bedrock"))
+ {
+ // Bedrock edition, not Java
+ }
+ if (name.contains(QStringLiteral("gamepass"), Qt::CaseInsensitive))
+ {
+ hasGamePass = true;
+ }
+ }
+
+ m_credentials.entitlements.ownsMinecraft = hasMinecraft || hasGamePass;
+ m_credentials.entitlements.canPlayMinecraft = hasMinecraft || hasGamePass;
+ m_credentials.entitlements.validity = TokenValidity::Certain;
+
+ return true;
+ }
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/GameEntitlementsStep.hpp b/archived/projt-launcher/launcher/minecraft/auth/steps/GameEntitlementsStep.hpp
new file mode 100644
index 0000000000..6b306107ca
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/GameEntitlementsStep.hpp
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <memory>
+
+#include "Step.hpp"
+#include "net/Download.h"
+#include "net/NetJob.h"
+
+namespace projt::minecraft::auth
+{
+
+ /**
+ * Game entitlements verification step.
+ *
+ * Fetches license/entitlement information to verify game ownership.
+ * This determines whether the user owns Minecraft and can play it.
+ *
+ * Endpoint: https://api.minecraftservices.com/entitlements/license
+ */
+ class GameEntitlementsStep : public Step
+ {
+ Q_OBJECT
+
+ public:
+ explicit GameEntitlementsStep(Credentials& credentials) noexcept;
+ ~GameEntitlementsStep() noexcept override = default;
+
+ [[nodiscard]] QString description() const override;
+
+ public slots:
+ void execute() override;
+
+ private slots:
+ void onRequestCompleted();
+
+ private:
+ [[nodiscard]] bool parseEntitlementsResponse(const QByteArray& data);
+
+ QString m_requestId;
+
+ std::shared_ptr<QByteArray> m_response;
+ Net::Download::Ptr m_request;
+ NetJob::Ptr m_task;
+ };
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/MicrosoftOAuthStep.cpp b/archived/projt-launcher/launcher/minecraft/auth/steps/MicrosoftOAuthStep.cpp
new file mode 100644
index 0000000000..a0ad612481
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/MicrosoftOAuthStep.cpp
@@ -0,0 +1,319 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "MicrosoftOAuthStep.hpp"
+
+#include <QAbstractOAuth2>
+#include <QCoreApplication>
+#include <QFileInfo>
+#include <QNetworkRequest>
+#include <QOAuthHttpServerReplyHandler>
+#include <QOAuthOobReplyHandler>
+#include <QProcess>
+#include <QSettings>
+#include <QStandardPaths>
+
+#include "Application.h"
+#include "BuildConfig.h"
+
+namespace projt::minecraft::auth
+{
+
+ namespace
+ {
+
+ /**
+ * Custom OOB reply handler that forwards OAuth callbacks from the application.
+ */
+ class CustomSchemeReplyHandler : public QOAuthOobReplyHandler
+ {
+ Q_OBJECT
+
+ public:
+ explicit CustomSchemeReplyHandler(QObject* parent = nullptr) : QOAuthOobReplyHandler(parent)
+ {
+ connect(APPLICATION, &Application::oauthReplyRecieved, this, &QOAuthOobReplyHandler::callbackReceived);
+ }
+
+ ~CustomSchemeReplyHandler() override
+ {
+ disconnect(APPLICATION,
+ &Application::oauthReplyRecieved,
+ this,
+ &QOAuthOobReplyHandler::callbackReceived);
+ }
+
+ [[nodiscard]] QString callback() const override
+ {
+ return BuildConfig.LAUNCHER_APP_BINARY_NAME + QStringLiteral("://oauth/microsoft");
+ }
+ };
+
+ /**
+ * Check if the custom URL scheme handler is registered with the OS AND
+ * that the registered handler points to the currently running binary.
+ *
+ * This is important when multiple builds of the launcher coexist on the same
+ * machine (e.g. an installed release and a locally compiled dev build).
+ * If the registered handler points to a *different* binary, the OAuth callback
+ * URL would be intercepted by that other instance instead of the current one,
+ * causing the login flow to fail silently. In that case we fall back to the
+ * HTTP loopback server handler which is always self-contained.
+ */
+ [[nodiscard]] bool isCustomSchemeRegistered()
+ {
+#ifdef Q_OS_LINUX
+ QProcess process;
+ process.start(QStringLiteral("xdg-mime"),
+ { QStringLiteral("query"),
+ QStringLiteral("default"),
+ QStringLiteral("x-scheme-handler/") + BuildConfig.LAUNCHER_APP_BINARY_NAME });
+ process.waitForFinished();
+ const QString output = process.readAllStandardOutput().trimmed();
+ if (!output.contains(BuildConfig.LAUNCHER_APP_BINARY_NAME))
+ return false;
+
+ // Also verify the registered .desktop entry resolves to our own binary.
+ // xdg-mime returns something like "projtlauncher.desktop"; locate it and
+ // read the Exec= line to compare against our own executable path.
+ const QString desktopFileName = output.section(QLatin1Char('\n'), 0, 0).trimmed();
+ const QStringList dataDirs = QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation);
+ for (const QString& dataDir : dataDirs)
+ {
+ const QString desktopPath = dataDir + QStringLiteral("/applications/") + desktopFileName;
+ QSettings desktopFile(desktopPath, QSettings::IniFormat);
+ desktopFile.beginGroup(QStringLiteral("Desktop Entry"));
+ const QString execLine = desktopFile.value(QStringLiteral("Exec")).toString();
+ desktopFile.endGroup();
+ if (execLine.isEmpty())
+ continue;
+ // Exec= may contain %U or similar; take only the binary part.
+ const QString registeredBin = execLine.section(QLatin1Char(' '), 0, 0);
+ const QFileInfo currentBin(QCoreApplication::applicationFilePath());
+ const QFileInfo registeredBinInfo(registeredBin);
+ if (registeredBinInfo.canonicalFilePath() == currentBin.canonicalFilePath())
+ return true;
+ // Registered handler is a different binary → do not use custom scheme.
+ qDebug() << "Custom URL scheme is registered for a different binary (" << registeredBin
+ << ") — falling back to HTTP loopback handler.";
+ return false;
+ }
+ return true; // Could not verify; assume it's ours.
+#elif defined(Q_OS_WIN)
+ const QString regPath =
+ QStringLiteral("HKEY_CURRENT_USER\\Software\\Classes\\%1").arg(BuildConfig.LAUNCHER_APP_BINARY_NAME);
+ const QSettings settings(regPath, QSettings::NativeFormat);
+ if (!settings.contains(QStringLiteral("shell/open/command/.")))
+ return false;
+
+ // Verify that the registered command actually points to this binary.
+ // The registry value looks like: "C:\path\to\launcher.exe" "%1"
+ QString registeredCmd = settings.value(QStringLiteral("shell/open/command/.")).toString();
+ // Strip surrounding quotes from the executable portion.
+ if (registeredCmd.startsWith(QLatin1Char('"')))
+ {
+ registeredCmd = registeredCmd.mid(1);
+ const int closeQuote = registeredCmd.indexOf(QLatin1Char('"'));
+ if (closeQuote >= 0)
+ registeredCmd = registeredCmd.left(closeQuote);
+ }
+ else
+ {
+ // No quotes — executable ends at the first space.
+ const int spaceIdx = registeredCmd.indexOf(QLatin1Char(' '));
+ if (spaceIdx >= 0)
+ registeredCmd = registeredCmd.left(spaceIdx);
+ }
+
+ const QFileInfo currentBin(QCoreApplication::applicationFilePath());
+ const QFileInfo registeredBin(registeredCmd);
+ if (registeredBin.canonicalFilePath().compare(currentBin.canonicalFilePath(), Qt::CaseInsensitive) == 0)
+ return true;
+
+ // The URL scheme is registered, but for a different launcher binary.
+ // Fall back to the HTTP loopback handler so our OAuth callback reaches us.
+ qDebug() << "Custom URL scheme is registered for a different binary (" << registeredCmd
+ << ") — falling back to HTTP loopback handler.";
+ return false;
+#else
+ return true;
+#endif
+ }
+
+ } // namespace
+
+ MicrosoftOAuthStep::MicrosoftOAuthStep(Credentials& credentials, bool silentRefresh) noexcept
+ : Step(credentials),
+ m_silentRefresh(silentRefresh),
+ m_clientId(APPLICATION->getMSAClientID())
+ {
+ setupOAuthHandlers();
+ }
+
+ QString MicrosoftOAuthStep::description() const
+ {
+ return m_silentRefresh ? tr("Refreshing Microsoft account token.") : tr("Logging in with Microsoft account.");
+ }
+
+ void MicrosoftOAuthStep::setupOAuthHandlers()
+ {
+ // Choose appropriate reply handler based on environment
+ if (shouldUseCustomScheme())
+ {
+ m_oauth.setReplyHandler(new CustomSchemeReplyHandler(this));
+ }
+ else
+ {
+ auto* httpHandler = new QOAuthHttpServerReplyHandler(this);
+ httpHandler->setCallbackText(QStringLiteral(R"XXX(
+ <noscript>
+ <meta http-equiv="Refresh" content="0; URL=%1" />
+ </noscript>
+ Login Successful, redirecting...
+ <script>
+ window.location.replace("%1");
+ </script>
+ )XXX")
+ .arg(BuildConfig.LOGIN_CALLBACK_URL));
+ m_oauth.setReplyHandler(httpHandler);
+ }
+
+ // Configure OAuth endpoints
+ m_oauth.setAuthorizationUrl(
+ QUrl(QStringLiteral("https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize")));
+ m_oauth.setAccessTokenUrl(
+ QUrl(QStringLiteral("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")));
+ m_oauth.setScope(QStringLiteral("XboxLive.SignIn XboxLive.offline_access"));
+ m_oauth.setClientIdentifier(m_clientId);
+ m_oauth.setNetworkAccessManager(APPLICATION->network().get());
+
+ // Connect signals
+ connect(&m_oauth, &QOAuth2AuthorizationCodeFlow::granted, this, &MicrosoftOAuthStep::onGranted);
+ connect(&m_oauth,
+ &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser,
+ this,
+ &MicrosoftOAuthStep::openBrowserRequested);
+ connect(&m_oauth,
+ &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser,
+ this,
+ &MicrosoftOAuthStep::browserAuthRequired);
+ connect(&m_oauth, &QOAuth2AuthorizationCodeFlow::requestFailed, this, &MicrosoftOAuthStep::onRequestFailed);
+ connect(&m_oauth, &QOAuth2AuthorizationCodeFlow::error, this, &MicrosoftOAuthStep::onError);
+ connect(&m_oauth,
+ &QOAuth2AuthorizationCodeFlow::extraTokensChanged,
+ this,
+ &MicrosoftOAuthStep::onExtraTokensChanged);
+ connect(&m_oauth,
+ &QOAuth2AuthorizationCodeFlow::clientIdentifierChanged,
+ this,
+ &MicrosoftOAuthStep::onClientIdChanged);
+ }
+
+ bool MicrosoftOAuthStep::shouldUseCustomScheme() const
+ {
+ // Use HTTP server handler for AppImage, portable, or unregistered scheme
+ const bool isAppImage = QCoreApplication::applicationFilePath().startsWith(QStringLiteral("/tmp/.mount_"));
+ const bool isPortable = APPLICATION->isPortable();
+ return !isAppImage && !isPortable && isCustomSchemeRegistered();
+ }
+
+ void MicrosoftOAuthStep::execute()
+ {
+ if (m_silentRefresh)
+ {
+ // Validate preconditions for silent refresh
+ if (m_credentials.msaClientId != m_clientId)
+ {
+ emit completed(StepResult::Disabled, tr("Microsoft client ID has changed. Please log in again."));
+ return;
+ }
+
+ if (!m_credentials.msaToken.hasRefreshToken())
+ {
+ emit completed(StepResult::Disabled, tr("No refresh token available. Please log in again."));
+ return;
+ }
+
+ m_oauth.setRefreshToken(m_credentials.msaToken.refreshToken);
+ m_oauth.refreshAccessToken();
+ }
+ else
+ {
+ // Interactive login - clear existing credentials
+ m_credentials = Credentials{};
+ m_credentials.msaClientId = m_clientId;
+
+ // Force account selection prompt
+ m_oauth.setModifyParametersFunction(
+ [](QAbstractOAuth::Stage, QMultiMap<QString, QVariant>* params)
+ { params->insert(QStringLiteral("prompt"), QStringLiteral("select_account")); });
+
+ m_oauth.grant();
+ }
+ }
+
+ void MicrosoftOAuthStep::onGranted()
+ {
+ m_credentials.msaClientId = m_oauth.clientIdentifier();
+ m_credentials.msaToken.issuedAt = QDateTime::currentDateTimeUtc();
+ m_credentials.msaToken.expiresAt = m_oauth.expirationAt();
+ m_credentials.msaToken.metadata = m_oauth.extraTokens();
+ m_credentials.msaToken.refreshToken = m_oauth.refreshToken();
+ m_credentials.msaToken.accessToken = m_oauth.token();
+ m_credentials.msaToken.validity = TokenValidity::Certain;
+
+ emit completed(StepResult::Continue, tr("Microsoft authentication successful."));
+ }
+
+ void MicrosoftOAuthStep::onRequestFailed(QAbstractOAuth2::Error err)
+ {
+ StepResult result = StepResult::HardFailure;
+
+ if (m_oauth.status() == QAbstractOAuth::Status::Granted || m_silentRefresh)
+ {
+ result = (err == QAbstractOAuth2::Error::NetworkError) ? StepResult::Offline : StepResult::SoftFailure;
+ }
+
+ const QString message =
+ m_silentRefresh ? tr("Failed to refresh Microsoft token.") : tr("Microsoft authentication failed.");
+ qWarning() << message;
+ emit completed(result, message);
+ }
+
+ void MicrosoftOAuthStep::onError(const QString& error, const QString& errorDescription, const QUrl& /*uri*/)
+ {
+ qWarning() << "OAuth error:" << error << "-" << errorDescription;
+ emit completed(StepResult::HardFailure, errorDescription.isEmpty() ? error : errorDescription);
+ }
+
+ void MicrosoftOAuthStep::onExtraTokensChanged(const QVariantMap& tokens)
+ {
+ m_credentials.msaToken.metadata = tokens;
+ }
+
+ void MicrosoftOAuthStep::onClientIdChanged(const QString& clientId)
+ {
+ m_credentials.msaClientId = clientId;
+ }
+
+} // namespace projt::minecraft::auth
+
+#include "MicrosoftOAuthStep.moc"
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/MicrosoftOAuthStep.hpp b/archived/projt-launcher/launcher/minecraft/auth/steps/MicrosoftOAuthStep.hpp
new file mode 100644
index 0000000000..16df0da21a
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/MicrosoftOAuthStep.hpp
@@ -0,0 +1,85 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QOAuth2AuthorizationCodeFlow>
+#include <QString>
+
+#include "Step.hpp"
+
+namespace projt::minecraft::auth
+{
+
+ /**
+ * Microsoft OAuth 2.0 Authorization Code Flow step.
+ *
+ * Handles interactive browser-based login or silent token refresh using
+ * the standard OAuth 2.0 authorization code flow. Upon success, populates
+ * the MSA token in Credentials.
+ *
+ * This step supports two modes:
+ * - Interactive: Opens browser for user login
+ * - Silent: Attempts refresh using stored refresh token
+ */
+ class MicrosoftOAuthStep : public Step
+ {
+ Q_OBJECT
+
+ public:
+ /**
+ * Construct a new MSA OAuth step.
+ * @param credentials Credentials to populate with MSA token.
+ * @param silentRefresh If true, attempt silent refresh; if false, interactive login.
+ */
+ explicit MicrosoftOAuthStep(Credentials& credentials, bool silentRefresh = false) noexcept;
+ ~MicrosoftOAuthStep() noexcept override = default;
+
+ [[nodiscard]] QString description() const override;
+
+ public slots:
+ void execute() override;
+
+ signals:
+ /**
+ * Emitted when browser authorization is required (interactive mode).
+ * @param url URL to open in user's browser.
+ */
+ void openBrowserRequested(const QUrl& url);
+
+ private slots:
+ void onGranted();
+ void onRequestFailed(QAbstractOAuth2::Error error);
+ void onError(const QString& error, const QString& errorDescription, const QUrl& uri);
+ void onExtraTokensChanged(const QVariantMap& tokens);
+ void onClientIdChanged(const QString& clientId);
+
+ private:
+ void setupOAuthHandlers();
+ [[nodiscard]] bool shouldUseCustomScheme() const;
+
+ bool m_silentRefresh = false;
+ QString m_clientId;
+ QOAuth2AuthorizationCodeFlow m_oauth;
+ };
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftProfileFetchStep.cpp b/archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftProfileFetchStep.cpp
new file mode 100644
index 0000000000..5529337ef5
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftProfileFetchStep.cpp
@@ -0,0 +1,170 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "MinecraftProfileFetchStep.hpp"
+
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonParseError>
+#include <QNetworkRequest>
+
+#include "Application.h"
+#include "net/NetUtils.h"
+#include "net/RawHeaderProxy.h"
+
+namespace projt::minecraft::auth
+{
+
+ namespace
+ {
+
+ constexpr auto kMinecraftProfileUrl = "https://api.minecraftservices.com/minecraft/profile";
+
+ } // namespace
+
+ MinecraftProfileFetchStep::MinecraftProfileFetchStep(Credentials& credentials) noexcept : Step(credentials)
+ {}
+
+ QString MinecraftProfileFetchStep::description() const
+ {
+ return tr("Fetching Minecraft profile.");
+ }
+
+ void MinecraftProfileFetchStep::execute()
+ {
+ const QUrl url(QString::fromLatin1(kMinecraftProfileUrl));
+ const QString authHeader = QStringLiteral("Bearer %1").arg(m_credentials.minecraftAccessToken.accessToken);
+
+ const auto headers = QList<Net::HeaderPair>{ { "Content-Type", "application/json" },
+ { "Accept", "application/json" },
+ { "Authorization", authHeader.toUtf8() } };
+
+ m_response = std::make_shared<QByteArray>();
+ m_request = Net::Download::makeByteArray(url, m_response);
+ m_request->addHeaderProxy(new Net::RawHeaderProxy(headers));
+
+ m_task = NetJob::Ptr::create(QStringLiteral("MinecraftProfileFetch"), APPLICATION->network());
+ m_task->setAskRetry(false);
+ m_task->addNetAction(m_request);
+
+ connect(m_task.get(), &Task::finished, this, &MinecraftProfileFetchStep::onRequestCompleted);
+ m_task->start();
+ }
+
+ void MinecraftProfileFetchStep::onRequestCompleted()
+ {
+ // 404 = no profile exists (valid state for new accounts)
+ if (m_request->error() == QNetworkReply::ContentNotFoundError)
+ {
+ m_credentials.profile = MinecraftJavaProfile{};
+ emit completed(StepResult::Continue, tr("Account has no Minecraft profile."));
+ return;
+ }
+
+ if (m_request->error() != QNetworkReply::NoError)
+ {
+ qWarning() << "Minecraft profile fetch error:";
+ qWarning() << " HTTP Status:" << m_request->replyStatusCode();
+ qWarning() << " Error:" << m_request->error() << m_request->errorString();
+ qWarning() << " Response:" << QString::fromUtf8(*m_response);
+
+ const StepResult result =
+ Net::isApplicationError(m_request->error()) ? StepResult::SoftFailure : StepResult::Offline;
+
+ emit completed(result, tr("Failed to fetch Minecraft profile: %1").arg(m_request->errorString()));
+ return;
+ }
+
+ if (!parseProfileResponse(*m_response))
+ {
+ m_credentials.profile = MinecraftJavaProfile{};
+ emit completed(StepResult::SoftFailure, tr("Could not parse Minecraft profile response."));
+ return;
+ }
+
+ emit completed(StepResult::Continue, tr("Got Minecraft profile."));
+ }
+
+ bool MinecraftProfileFetchStep::parseProfileResponse(const QByteArray& data)
+ {
+ QJsonParseError err;
+ const auto doc = QJsonDocument::fromJson(data, &err);
+ if (err.error != QJsonParseError::NoError || !doc.isObject())
+ {
+ qWarning() << "Failed to parse Minecraft profile:" << err.errorString();
+ return false;
+ }
+
+ const auto obj = doc.object();
+
+ // Basic profile info
+ m_credentials.profile.id = obj.value(QStringLiteral("id")).toString();
+ m_credentials.profile.name = obj.value(QStringLiteral("name")).toString();
+
+ if (m_credentials.profile.id.isEmpty())
+ {
+ return false;
+ }
+
+ // Parse skins
+ const auto skins = obj.value(QStringLiteral("skins")).toArray();
+ for (const auto& skinVal : skins)
+ {
+ const auto skinObj = skinVal.toObject();
+ const QString state = skinObj.value(QStringLiteral("state")).toString();
+
+ if (state == QStringLiteral("ACTIVE"))
+ {
+ m_credentials.profile.skin.id = skinObj.value(QStringLiteral("id")).toString();
+ m_credentials.profile.skin.url = skinObj.value(QStringLiteral("url")).toString();
+ m_credentials.profile.skin.variant = skinObj.value(QStringLiteral("variant")).toString();
+ break;
+ }
+ }
+
+ // Parse capes
+ const auto capes = obj.value(QStringLiteral("capes")).toArray();
+ for (const auto& capeVal : capes)
+ {
+ const auto capeObj = capeVal.toObject();
+ const QString capeId = capeObj.value(QStringLiteral("id")).toString();
+
+ PlayerCape cape;
+ cape.id = capeId;
+ cape.url = capeObj.value(QStringLiteral("url")).toString();
+ cape.alias = capeObj.value(QStringLiteral("alias")).toString();
+
+ m_credentials.profile.capes.insert(capeId, cape);
+
+ // Track active cape
+ const QString state = capeObj.value(QStringLiteral("state")).toString();
+ if (state == QStringLiteral("ACTIVE"))
+ {
+ m_credentials.profile.activeCapeId = capeId;
+ }
+ }
+
+ m_credentials.profile.validity = TokenValidity::Certain;
+ return true;
+ }
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftProfileFetchStep.hpp b/archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftProfileFetchStep.hpp
new file mode 100644
index 0000000000..f9da7960e2
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftProfileFetchStep.hpp
@@ -0,0 +1,68 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <memory>
+
+#include "Step.hpp"
+#include "net/Download.h"
+#include "net/NetJob.h"
+
+namespace projt::minecraft::auth
+{
+
+ /**
+ * Minecraft Java profile fetch step.
+ *
+ * Fetches the Minecraft Java Edition profile (UUID, username, skins, capes).
+ * A profile may not exist if the user hasn't bought the game or set up
+ * their profile name yet.
+ *
+ * Endpoint: https://api.minecraftservices.com/minecraft/profile
+ */
+ class MinecraftProfileFetchStep : public Step
+ {
+ Q_OBJECT
+
+ public:
+ explicit MinecraftProfileFetchStep(Credentials& credentials) noexcept;
+ ~MinecraftProfileFetchStep() noexcept override = default;
+
+ [[nodiscard]] QString description() const override;
+
+ public slots:
+ void execute() override;
+
+ private slots:
+ void onRequestCompleted();
+
+ private:
+ [[nodiscard]] bool parseProfileResponse(const QByteArray& data);
+
+ std::shared_ptr<QByteArray> m_response;
+ Net::Download::Ptr m_request;
+ NetJob::Ptr m_task;
+ };
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftServicesLoginStep.cpp b/archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftServicesLoginStep.cpp
new file mode 100644
index 0000000000..eba64cc6f0
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftServicesLoginStep.cpp
@@ -0,0 +1,138 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "MinecraftServicesLoginStep.hpp"
+
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonParseError>
+#include <QNetworkRequest>
+#include <QUrl>
+
+#include "Application.h"
+#include "minecraft/Logging.h"
+#include "net/NetUtils.h"
+#include "net/RawHeaderProxy.h"
+
+namespace projt::minecraft::auth
+{
+
+ namespace
+ {
+
+ constexpr auto kMinecraftLoginUrl = "https://api.minecraftservices.com/launcher/login";
+
+ /**
+ * Parse Minecraft services authentication response.
+ */
+ [[nodiscard]] bool parseMinecraftAuthResponse(const QByteArray& data, AuthToken& token)
+ {
+ QJsonParseError err;
+ const auto doc = QJsonDocument::fromJson(data, &err);
+ if (err.error != QJsonParseError::NoError || !doc.isObject())
+ {
+ qWarning() << "Failed to parse Minecraft login response:" << err.errorString();
+ return false;
+ }
+
+ const auto obj = doc.object();
+
+ token.accessToken = obj.value(QStringLiteral("access_token")).toString();
+ token.issuedAt = QDateTime::currentDateTimeUtc();
+
+ const int expiresIn = obj.value(QStringLiteral("expires_in")).toInt();
+ if (expiresIn > 0)
+ {
+ token.expiresAt = token.issuedAt.addSecs(expiresIn);
+ }
+
+ // Store token type and other metadata
+ token.metadata.insert(QStringLiteral("token_type"), obj.value(QStringLiteral("token_type")).toString());
+
+ token.validity = TokenValidity::Certain;
+ return !token.accessToken.isEmpty();
+ }
+
+ } // namespace
+
+ MinecraftServicesLoginStep::MinecraftServicesLoginStep(Credentials& credentials) noexcept : Step(credentials)
+ {}
+
+ QString MinecraftServicesLoginStep::description() const
+ {
+ return tr("Logging in to Minecraft services.");
+ }
+
+ void MinecraftServicesLoginStep::execute()
+ {
+ const QString uhs = m_credentials.minecraftServicesToken.metadata.value(QStringLiteral("uhs")).toString();
+ const QString xToken = m_credentials.minecraftServicesToken.accessToken;
+
+ const QString requestBody = QStringLiteral(R"({
+ "xtoken": "XBL3.0 x=%1;%2",
+ "platform": "PC_LAUNCHER"
+ })")
+ .arg(uhs, xToken);
+
+ const QUrl url(QString::fromLatin1(kMinecraftLoginUrl));
+ const auto headers =
+ QList<Net::HeaderPair>{ { "Content-Type", "application/json" }, { "Accept", "application/json" } };
+
+ m_response = std::make_shared<QByteArray>();
+ m_request = Net::Upload::makeByteArray(url, m_response, requestBody.toUtf8());
+ m_request->addHeaderProxy(new Net::RawHeaderProxy(headers));
+
+ m_task = NetJob::Ptr::create(QStringLiteral("MinecraftServicesLogin"), APPLICATION->network());
+ m_task->setAskRetry(false);
+ m_task->addNetAction(m_request);
+
+ connect(m_task.get(), &Task::finished, this, &MinecraftServicesLoginStep::onRequestCompleted);
+ m_task->start();
+
+ qDebug() << "Getting Minecraft access token...";
+ }
+
+ void MinecraftServicesLoginStep::onRequestCompleted()
+ {
+ qCDebug(authCredentials()) << *m_response;
+
+ if (m_request->error() != QNetworkReply::NoError)
+ {
+ qWarning() << "Minecraft login error:" << m_request->error();
+
+ const StepResult result =
+ Net::isApplicationError(m_request->error()) ? StepResult::SoftFailure : StepResult::Offline;
+
+ emit completed(result, tr("Failed to get Minecraft access token: %1").arg(m_request->errorString()));
+ return;
+ }
+
+ if (!parseMinecraftAuthResponse(*m_response, m_credentials.minecraftAccessToken))
+ {
+ qWarning() << "Could not parse Minecraft login response";
+ emit completed(StepResult::SoftFailure, tr("Failed to parse Minecraft access token response."));
+ return;
+ }
+
+ emit completed(StepResult::Continue, tr("Got Minecraft access token."));
+ }
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftServicesLoginStep.hpp b/archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftServicesLoginStep.hpp
new file mode 100644
index 0000000000..95428b6c9a
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/MinecraftServicesLoginStep.hpp
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <memory>
+
+#include "Step.hpp"
+#include "net/NetJob.h"
+#include "net/Upload.h"
+
+namespace projt::minecraft::auth
+{
+
+ /**
+ * Minecraft Services login step.
+ *
+ * Exchanges the XSTS token for a Minecraft access token (Yggdrasil-style).
+ * This token is used to authenticate with Minecraft game servers.
+ *
+ * Endpoint: https://api.minecraftservices.com/launcher/login
+ */
+ class MinecraftServicesLoginStep : public Step
+ {
+ Q_OBJECT
+
+ public:
+ explicit MinecraftServicesLoginStep(Credentials& credentials) noexcept;
+ ~MinecraftServicesLoginStep() noexcept override = default;
+
+ [[nodiscard]] QString description() const override;
+
+ public slots:
+ void execute() override;
+
+ private slots:
+ void onRequestCompleted();
+
+ private:
+ std::shared_ptr<QByteArray> m_response;
+ Net::Upload::Ptr m_request;
+ NetJob::Ptr m_task;
+ };
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/SkinDownloadStep.cpp b/archived/projt-launcher/launcher/minecraft/auth/steps/SkinDownloadStep.cpp
new file mode 100644
index 0000000000..e4d7283a37
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/SkinDownloadStep.cpp
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "SkinDownloadStep.hpp"
+
+#include <QNetworkRequest>
+
+#include "Application.h"
+
+namespace projt::minecraft::auth
+{
+
+ SkinDownloadStep::SkinDownloadStep(Credentials& credentials) noexcept : Step(credentials)
+ {}
+
+ QString SkinDownloadStep::description() const
+ {
+ return tr("Downloading player skin.");
+ }
+
+ void SkinDownloadStep::execute()
+ {
+ // Skip if no skin URL available
+ if (m_credentials.profile.skin.url.isEmpty())
+ {
+ emit completed(StepResult::Continue, tr("No skin to download."));
+ return;
+ }
+
+ const QUrl url(m_credentials.profile.skin.url);
+
+ m_response = std::make_shared<QByteArray>();
+ m_request = Net::Download::makeByteArray(url, m_response);
+
+ m_task = NetJob::Ptr::create(QStringLiteral("SkinDownload"), APPLICATION->network());
+ m_task->setAskRetry(false);
+ m_task->addNetAction(m_request);
+
+ connect(m_task.get(), &Task::finished, this, &SkinDownloadStep::onRequestCompleted);
+ m_task->start();
+ }
+
+ void SkinDownloadStep::onRequestCompleted()
+ {
+ // Skin download is optional - always continue regardless of result
+ if (m_request->error() == QNetworkReply::NoError)
+ {
+ m_credentials.profile.skin.imageData = *m_response;
+ emit completed(StepResult::Continue, tr("Got player skin."));
+ }
+ else
+ {
+ qWarning() << "Failed to download skin:" << m_request->errorString();
+ emit completed(StepResult::Continue, tr("Skin download failed (continuing)."));
+ }
+ }
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/SkinDownloadStep.hpp b/archived/projt-launcher/launcher/minecraft/auth/steps/SkinDownloadStep.hpp
new file mode 100644
index 0000000000..c94fe825cd
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/SkinDownloadStep.hpp
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <memory>
+
+#include "Step.hpp"
+#include "net/Download.h"
+#include "net/NetJob.h"
+
+namespace projt::minecraft::auth
+{
+
+ /**
+ * Skin image download step.
+ *
+ * Downloads the player's skin image data from the URL in the profile.
+ * This is an optional step used for display in the launcher UI.
+ */
+ class SkinDownloadStep : public Step
+ {
+ Q_OBJECT
+
+ public:
+ explicit SkinDownloadStep(Credentials& credentials) noexcept;
+ ~SkinDownloadStep() noexcept override = default;
+
+ [[nodiscard]] QString description() const override;
+
+ public slots:
+ void execute() override;
+
+ private slots:
+ void onRequestCompleted();
+
+ private:
+ std::shared_ptr<QByteArray> m_response;
+ Net::Download::Ptr m_request;
+ NetJob::Ptr m_task;
+ };
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/Step.hpp b/archived/projt-launcher/launcher/minecraft/auth/steps/Step.hpp
new file mode 100644
index 0000000000..7afb7159cb
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/Step.hpp
@@ -0,0 +1,128 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <QNetworkReply>
+#include <QObject>
+#include <QString>
+
+#include "QObjectPtr.h"
+
+namespace projt::minecraft::auth
+{
+
+ /**
+ * Result state for authentication pipeline steps.
+ * Each step emits one of these states upon completion.
+ */
+ enum class StepResult
+ {
+ Continue, ///< Step succeeded, proceed to next step
+ Succeeded, ///< Final success - authentication complete
+ Offline, ///< Network unavailable - soft failure, can retry
+ SoftFailure, ///< Recoverable error - partial auth, can retry
+ HardFailure, ///< Unrecoverable error - tokens invalid
+ Disabled, ///< Account disabled (e.g., client ID changed)
+ Gone ///< Account no longer exists
+ };
+
+ // Forward declaration
+ struct Credentials;
+
+ /**
+ * Abstract base class for authentication pipeline steps.
+ *
+ * Each step performs a discrete authentication action (e.g., OAuth exchange,
+ * token validation, profile fetch) and emits `completed` when done.
+ *
+ * Steps are designed to be stateless between runs - all persistent data
+ * is stored in the Credentials object passed at construction.
+ */
+ class Step : public QObject
+ {
+ Q_OBJECT
+
+ public:
+ using Ptr = shared_qobject_ptr<Step>;
+
+ /**
+ * Construct a step with a reference to the credential store.
+ * @param credentials Mutable reference to authentication data.
+ */
+ explicit Step(Credentials& credentials) noexcept : QObject(nullptr), m_credentials(credentials)
+ {}
+
+ ~Step() noexcept override = default;
+
+ // Rule of Zero - no copy/move (QObject constraint)
+ Step(const Step&) = delete;
+ Step& operator=(const Step&) = delete;
+ Step(Step&&) = delete;
+ Step& operator=(Step&&) = delete;
+
+ /**
+ * Human-readable description of what this step does.
+ * Used for progress display and logging.
+ */
+ [[nodiscard]] virtual QString description() const = 0;
+
+ public slots:
+ /**
+ * Execute this authentication step.
+ * Implementations must emit `completed` when done (success or failure).
+ */
+ virtual void execute() = 0;
+
+ /**
+ * Request cancellation of an in-progress step.
+ * Default implementation does nothing. Override for cancellable steps.
+ */
+ virtual void cancel() noexcept
+ {}
+
+ signals:
+ /**
+ * Emitted when the step completes (successfully or with error).
+ * @param result The outcome of this step.
+ * @param message Human-readable status message.
+ */
+ void completed(StepResult result, QString message);
+
+ /**
+ * Emitted by OAuth steps when browser authorization is required.
+ * @param url The URL to open in the user's browser.
+ */
+ void browserAuthRequired(const QUrl& url);
+
+ /**
+ * Emitted by device code flow steps when user action is required.
+ * @param verificationUrl URL to visit for authentication.
+ * @param userCode Code to enter at the URL.
+ * @param expiresInSecs Seconds until the code expires.
+ */
+ void deviceCodeReady(QString verificationUrl, QString userCode, int expiresInSecs);
+
+ protected:
+ Credentials& m_credentials;
+ };
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/Steps.hpp b/archived/projt-launcher/launcher/minecraft/auth/steps/Steps.hpp
new file mode 100644
index 0000000000..4cb8b8b07b
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/Steps.hpp
@@ -0,0 +1,51 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+/**
+ * @file Steps.hpp
+ * @brief Convenience header including all authentication pipeline steps.
+ *
+ * Include this header to get access to all step types:
+ * - MicrosoftOAuthStep: Browser-based MSA login
+ * - DeviceCodeAuthStep: Device code flow for console/headless
+ * - XboxLiveUserStep: XBL user token acquisition
+ * - XboxSecurityTokenStep: XSTS token for services
+ * - XboxProfileFetchStep: Xbox profile (optional)
+ * - MinecraftServicesLoginStep: Minecraft access token
+ * - MinecraftProfileFetchStep: Minecraft profile
+ * - GameEntitlementsStep: Game ownership check
+ * - SkinDownloadStep: Player skin image
+ */
+
+#pragma once
+
+#include "Credentials.hpp"
+#include "Step.hpp"
+
+#include "DeviceCodeAuthStep.hpp"
+#include "GameEntitlementsStep.hpp"
+#include "MicrosoftOAuthStep.hpp"
+#include "MinecraftProfileFetchStep.hpp"
+#include "MinecraftServicesLoginStep.hpp"
+#include "SkinDownloadStep.hpp"
+#include "XboxLiveUserStep.hpp"
+#include "XboxProfileFetchStep.hpp"
+#include "XboxSecurityTokenStep.hpp"
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/XboxLiveUserStep.cpp b/archived/projt-launcher/launcher/minecraft/auth/steps/XboxLiveUserStep.cpp
new file mode 100644
index 0000000000..ef48d4ab47
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/XboxLiveUserStep.cpp
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "XboxLiveUserStep.hpp"
+
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QNetworkRequest>
+
+#include "Application.h"
+#include "net/NetUtils.h"
+#include "net/RawHeaderProxy.h"
+
+namespace projt::minecraft::auth
+{
+
+ namespace
+ {
+
+ constexpr auto kXboxUserAuthUrl = "https://user.auth.xboxlive.com/user/authenticate";
+
+ /**
+ * Parse Xbox token response.
+ * Returns true on success, false on parse error.
+ */
+ [[nodiscard]] bool parseXboxTokenResponse(const QByteArray& data, AuthToken& token, const QString& tokenName)
+ {
+ QJsonParseError err;
+ const auto doc = QJsonDocument::fromJson(data, &err);
+ if (err.error != QJsonParseError::NoError || !doc.isObject())
+ {
+ qWarning() << "Failed to parse" << tokenName << "response:" << err.errorString();
+ return false;
+ }
+
+ const auto obj = doc.object();
+
+ // Parse issue and expiry times
+ const QString issued = obj.value(QStringLiteral("IssueInstant")).toString();
+ const QString expires = obj.value(QStringLiteral("NotAfter")).toString();
+ token.issuedAt = QDateTime::fromString(issued, Qt::ISODate);
+ token.expiresAt = QDateTime::fromString(expires, Qt::ISODate);
+ token.accessToken = obj.value(QStringLiteral("Token")).toString();
+
+ // Parse display claims for user hash (uhs)
+ const auto displayClaims = obj.value(QStringLiteral("DisplayClaims")).toObject();
+ const auto xui = displayClaims.value(QStringLiteral("xui")).toArray();
+ if (!xui.isEmpty())
+ {
+ const auto firstClaim = xui.first().toObject();
+ token.metadata.insert(QStringLiteral("uhs"), firstClaim.value(QStringLiteral("uhs")).toString());
+ }
+
+ token.validity = TokenValidity::Certain;
+
+ if (token.accessToken.isEmpty())
+ {
+ qWarning() << "Empty" << tokenName << "token received";
+ return false;
+ }
+
+ return true;
+ }
+
+ } // namespace
+
+ XboxLiveUserStep::XboxLiveUserStep(Credentials& credentials) noexcept : Step(credentials)
+ {}
+
+ QString XboxLiveUserStep::description() const
+ {
+ return tr("Authenticating with Xbox Live.");
+ }
+
+ void XboxLiveUserStep::execute()
+ {
+ const QString requestBody = QStringLiteral(R"({
+ "Properties": {
+ "AuthMethod": "RPS",
+ "SiteName": "user.auth.xboxlive.com",
+ "RpsTicket": "d=%1"
+ },
+ "RelyingParty": "http://auth.xboxlive.com",
+ "TokenType": "JWT"
+ })")
+ .arg(m_credentials.msaToken.accessToken);
+
+ const QUrl url(QString::fromLatin1(kXboxUserAuthUrl));
+ const auto headers = QList<Net::HeaderPair>{ { "Content-Type", "application/json" },
+ { "Accept", "application/json" },
+ { "x-xbl-contract-version", "1" } };
+
+ m_response = std::make_shared<QByteArray>();
+ m_request = Net::Upload::makeByteArray(url, m_response, requestBody.toUtf8());
+ m_request->addHeaderProxy(new Net::RawHeaderProxy(headers));
+
+ m_task = NetJob::Ptr::create(QStringLiteral("XboxLiveUserAuth"), APPLICATION->network());
+ m_task->setAskRetry(false);
+ m_task->addNetAction(m_request);
+
+ connect(m_task.get(), &Task::finished, this, &XboxLiveUserStep::onRequestCompleted);
+ m_task->start();
+
+ qDebug() << "Authenticating with Xbox Live...";
+ }
+
+ void XboxLiveUserStep::onRequestCompleted()
+ {
+ if (m_request->error() != QNetworkReply::NoError)
+ {
+ qWarning() << "Xbox Live user auth error:" << m_request->error();
+
+ const StepResult result =
+ Net::isApplicationError(m_request->error()) ? StepResult::SoftFailure : StepResult::Offline;
+
+ emit completed(result, tr("Xbox Live authentication failed: %1").arg(m_request->errorString()));
+ return;
+ }
+
+ if (!parseXboxTokenResponse(*m_response, m_credentials.xboxUserToken, QStringLiteral("User")))
+ {
+ emit completed(StepResult::SoftFailure, tr("Could not parse Xbox Live user token response."));
+ return;
+ }
+
+ emit completed(StepResult::Continue, tr("Got Xbox Live user token."));
+ }
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/XboxLiveUserStep.hpp b/archived/projt-launcher/launcher/minecraft/auth/steps/XboxLiveUserStep.hpp
new file mode 100644
index 0000000000..a8c0514599
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/XboxLiveUserStep.hpp
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <memory>
+
+#include "Step.hpp"
+#include "net/NetJob.h"
+#include "net/Upload.h"
+
+namespace projt::minecraft::auth
+{
+
+ /**
+ * Xbox Live User Authentication step.
+ *
+ * Exchanges the MSA token for an Xbox Live user token.
+ * This is the first step of Xbox authentication after MSA login.
+ *
+ * Endpoint: https://user.auth.xboxlive.com/user/authenticate
+ */
+ class XboxLiveUserStep : public Step
+ {
+ Q_OBJECT
+
+ public:
+ explicit XboxLiveUserStep(Credentials& credentials) noexcept;
+ ~XboxLiveUserStep() noexcept override = default;
+
+ [[nodiscard]] QString description() const override;
+
+ public slots:
+ void execute() override;
+
+ private slots:
+ void onRequestCompleted();
+
+ private:
+ std::shared_ptr<QByteArray> m_response;
+ Net::Upload::Ptr m_request;
+ NetJob::Ptr m_task;
+ };
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/XboxProfileFetchStep.cpp b/archived/projt-launcher/launcher/minecraft/auth/steps/XboxProfileFetchStep.cpp
new file mode 100644
index 0000000000..8660d4291f
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/XboxProfileFetchStep.cpp
@@ -0,0 +1,155 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "XboxProfileFetchStep.hpp"
+
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QNetworkRequest>
+#include <QUrlQuery>
+
+#include "Application.h"
+#include "minecraft/Logging.h"
+#include "net/NetUtils.h"
+#include "net/RawHeaderProxy.h"
+
+namespace projt::minecraft::auth
+{
+
+ namespace
+ {
+
+ constexpr auto kXboxProfileUrl = "https://profile.xboxlive.com/users/me/profile/settings";
+
+ // Profile settings to request
+ constexpr auto kProfileSettings = "GameDisplayName,AppDisplayName,AppDisplayPicRaw,GameDisplayPicRaw,"
+ "PublicGamerpic,ShowUserAsAvatar,Gamerscore,Gamertag,ModernGamertag,"
+ "ModernGamertagSuffix,UniqueModernGamertag,AccountTier,TenureLevel,"
+ "XboxOneRep,PreferredColor,Location,Bio,Watermarks,RealName,"
+ "RealNameOverride,IsQuarantined";
+
+ } // namespace
+
+ XboxProfileFetchStep::XboxProfileFetchStep(Credentials& credentials) noexcept : Step(credentials)
+ {}
+
+ QString XboxProfileFetchStep::description() const
+ {
+ return tr("Fetching Xbox profile.");
+ }
+
+ void XboxProfileFetchStep::execute()
+ {
+ QUrl url(QString::fromLatin1(kXboxProfileUrl));
+ QUrlQuery query;
+ query.addQueryItem(QStringLiteral("settings"), QString::fromLatin1(kProfileSettings));
+ url.setQuery(query);
+
+ const QString authHeader = QStringLiteral("XBL3.0 x=%1;%2")
+ .arg(m_credentials.xboxUserHash(), m_credentials.xboxServiceToken.accessToken);
+
+ const auto headers = QList<Net::HeaderPair>{ { "Content-Type", "application/json" },
+ { "Accept", "application/json" },
+ { "x-xbl-contract-version", "3" },
+ { "Authorization", authHeader.toUtf8() } };
+
+ m_response = std::make_shared<QByteArray>();
+ m_request = Net::Download::makeByteArray(url, m_response);
+ m_request->addHeaderProxy(new Net::RawHeaderProxy(headers));
+
+ m_task = NetJob::Ptr::create(QStringLiteral("XboxProfileFetch"), APPLICATION->network());
+ m_task->setAskRetry(false);
+ m_task->addNetAction(m_request);
+
+ connect(m_task.get(), &Task::finished, this, &XboxProfileFetchStep::onRequestCompleted);
+ m_task->start();
+
+ qDebug() << "Fetching Xbox profile...";
+ }
+
+ void XboxProfileFetchStep::onRequestCompleted()
+ {
+ if (m_request->error() != QNetworkReply::NoError)
+ {
+ qWarning() << "Xbox profile fetch error:" << m_request->error();
+ qCDebug(authCredentials()) << *m_response;
+
+ // Profile fetch is optional - continue even on failure
+ const StepResult result =
+ Net::isApplicationError(m_request->error()) ? StepResult::SoftFailure : StepResult::Offline;
+
+ emit completed(result, tr("Failed to fetch Xbox profile: %1").arg(m_request->errorString()));
+ return;
+ }
+
+ qCDebug(authCredentials()) << "Xbox profile:" << *m_response;
+
+ // Parse the response to extract gamertag
+ parseProfileResponse();
+
+ emit completed(StepResult::Continue, tr("Got Xbox profile."));
+ }
+
+ void XboxProfileFetchStep::parseProfileResponse()
+ {
+ QJsonParseError parseError;
+ const auto doc = QJsonDocument::fromJson(*m_response, &parseError);
+
+ if (parseError.error != QJsonParseError::NoError || !doc.isObject())
+ {
+ qWarning() << "Failed to parse Xbox profile response:" << parseError.errorString();
+ return;
+ }
+
+ const auto root = doc.object();
+ const auto profileUsers = root.value(QStringLiteral("profileUsers")).toArray();
+
+ if (profileUsers.isEmpty())
+ {
+ qWarning() << "No profile users in Xbox response";
+ return;
+ }
+
+ const auto user = profileUsers.first().toObject();
+ const auto settings = user.value(QStringLiteral("settings")).toArray();
+
+ for (const auto& settingValue : settings)
+ {
+ const auto setting = settingValue.toObject();
+ const auto id = setting.value(QStringLiteral("id")).toString();
+ const auto value = setting.value(QStringLiteral("value")).toString();
+
+ if (id == QStringLiteral("Gamertag"))
+ {
+ // Store gamertag in xboxServiceToken.metadata for legacy sync
+ // accountDisplayString() expects this in xboxApiToken.extra["gtg"]
+ m_credentials.xboxServiceToken.metadata[QStringLiteral("gtg")] = value;
+ qDebug() << "Got Xbox gamertag:" << value;
+ }
+ else if (id == QStringLiteral("GameDisplayPicRaw"))
+ {
+ m_credentials.xboxServiceToken.metadata[QStringLiteral("gamerPicUrl")] = value;
+ }
+ }
+ }
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/XboxProfileFetchStep.hpp b/archived/projt-launcher/launcher/minecraft/auth/steps/XboxProfileFetchStep.hpp
new file mode 100644
index 0000000000..35a7594acd
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/XboxProfileFetchStep.hpp
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <memory>
+
+#include "Step.hpp"
+#include "net/Download.h"
+#include "net/NetJob.h"
+
+namespace projt::minecraft::auth
+{
+
+ /**
+ * Xbox Live profile fetch step.
+ *
+ * Fetches the Xbox Live profile (gamertag, avatar, etc.) for logging
+ * and display purposes. This is an optional/informational step.
+ *
+ * Endpoint: https://profile.xboxlive.com/users/me/profile/settings
+ */
+ class XboxProfileFetchStep : public Step
+ {
+ Q_OBJECT
+
+ public:
+ explicit XboxProfileFetchStep(Credentials& credentials) noexcept;
+ ~XboxProfileFetchStep() noexcept override = default;
+
+ [[nodiscard]] QString description() const override;
+
+ public slots:
+ void execute() override;
+
+ private slots:
+ void onRequestCompleted();
+
+ private:
+ void parseProfileResponse();
+
+ std::shared_ptr<QByteArray> m_response;
+ Net::Download::Ptr m_request;
+ NetJob::Ptr m_task;
+ };
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/XboxSecurityTokenStep.cpp b/archived/projt-launcher/launcher/minecraft/auth/steps/XboxSecurityTokenStep.cpp
new file mode 100644
index 0000000000..6ed455c6b1
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/XboxSecurityTokenStep.cpp
@@ -0,0 +1,249 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#include "XboxSecurityTokenStep.hpp"
+
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonParseError>
+#include <QNetworkRequest>
+
+#include "Application.h"
+#include "minecraft/Logging.h"
+#include "net/NetUtils.h"
+#include "net/RawHeaderProxy.h"
+
+namespace projt::minecraft::auth
+{
+
+ namespace
+ {
+
+ constexpr auto kXstsAuthorizeUrl = "https://xsts.auth.xboxlive.com/xsts/authorize";
+
+ /**
+ * Parse Xbox token response.
+ */
+ [[nodiscard]] bool parseXboxTokenResponse(const QByteArray& data, AuthToken& token)
+ {
+ QJsonParseError err;
+ const auto doc = QJsonDocument::fromJson(data, &err);
+ if (err.error != QJsonParseError::NoError || !doc.isObject())
+ {
+ qWarning() << "Failed to parse XSTS response:" << err.errorString();
+ return false;
+ }
+
+ const auto obj = doc.object();
+
+ token.issuedAt = QDateTime::fromString(obj.value(QStringLiteral("IssueInstant")).toString(), Qt::ISODate);
+ token.expiresAt = QDateTime::fromString(obj.value(QStringLiteral("NotAfter")).toString(), Qt::ISODate);
+ token.accessToken = obj.value(QStringLiteral("Token")).toString();
+
+ const auto displayClaims = obj.value(QStringLiteral("DisplayClaims")).toObject();
+ const auto xui = displayClaims.value(QStringLiteral("xui")).toArray();
+ if (!xui.isEmpty())
+ {
+ const auto firstClaim = xui.first().toObject();
+ token.metadata.insert(QStringLiteral("uhs"), firstClaim.value(QStringLiteral("uhs")).toString());
+ }
+
+ token.validity = TokenValidity::Certain;
+ return !token.accessToken.isEmpty();
+ }
+
+ /**
+ * XSTS error code messages.
+ * See: https://wiki.vg/Microsoft_Authentication_Scheme#Authenticate_with_XSTS
+ */
+ struct XstsErrorInfo
+ {
+ int64_t code;
+ const char* message;
+ };
+
+ constexpr std::array kXstsErrors = {
+ XstsErrorInfo{ 2148916227, "This Microsoft account was banned by Xbox." },
+ XstsErrorInfo{ 2148916229, "This account is restricted. Please check parental controls." },
+ XstsErrorInfo{ 2148916233, "This account does not have an Xbox Live profile. Purchase the game first." },
+ XstsErrorInfo{ 2148916234, "Please accept Xbox Terms of Service and try again." },
+ XstsErrorInfo{ 2148916235, "Xbox Live is not available in your region." },
+ XstsErrorInfo{ 2148916236, "This account requires age verification." },
+ XstsErrorInfo{ 2148916237, "This account has reached its playtime limit." },
+ XstsErrorInfo{ 2148916238, "This account is underaged and not linked to a family." }
+ };
+
+ } // namespace
+
+ XboxSecurityTokenStep::XboxSecurityTokenStep(Credentials& credentials, XstsTarget target) noexcept
+ : Step(credentials),
+ m_target(target)
+ {}
+
+ QString XboxSecurityTokenStep::description() const
+ {
+ return tr("Getting authorization for %1 services.").arg(targetName());
+ }
+
+ QString XboxSecurityTokenStep::relyingParty() const
+ {
+ switch (m_target)
+ {
+ case XstsTarget::XboxLive: return QStringLiteral("http://xboxlive.com");
+ case XstsTarget::MinecraftServices: return QStringLiteral("rp://api.minecraftservices.com/");
+ }
+ Q_UNREACHABLE();
+ }
+
+ QString XboxSecurityTokenStep::targetName() const
+ {
+ switch (m_target)
+ {
+ case XstsTarget::XboxLive: return QStringLiteral("Xbox Live");
+ case XstsTarget::MinecraftServices: return QStringLiteral("Minecraft");
+ }
+ Q_UNREACHABLE();
+ }
+
+ AuthToken& XboxSecurityTokenStep::targetToken()
+ {
+ switch (m_target)
+ {
+ case XstsTarget::XboxLive: return m_credentials.xboxServiceToken;
+ case XstsTarget::MinecraftServices: return m_credentials.minecraftServicesToken;
+ }
+ Q_UNREACHABLE();
+ }
+
+ void XboxSecurityTokenStep::execute()
+ {
+ const QString requestBody = QStringLiteral(R"({
+ "Properties": {
+ "SandboxId": "RETAIL",
+ "UserTokens": ["%1"]
+ },
+ "RelyingParty": "%2",
+ "TokenType": "JWT"
+ })")
+ .arg(m_credentials.xboxUserToken.accessToken, relyingParty());
+
+ const QUrl url(QString::fromLatin1(kXstsAuthorizeUrl));
+ const auto headers =
+ QList<Net::HeaderPair>{ { "Content-Type", "application/json" }, { "Accept", "application/json" } };
+
+ m_response = std::make_shared<QByteArray>();
+ m_request = Net::Upload::makeByteArray(url, m_response, requestBody.toUtf8());
+ m_request->addHeaderProxy(new Net::RawHeaderProxy(headers));
+
+ m_task = NetJob::Ptr::create(QStringLiteral("XstsAuthorize"), APPLICATION->network());
+ m_task->setAskRetry(false);
+ m_task->addNetAction(m_request);
+
+ connect(m_task.get(), &Task::finished, this, &XboxSecurityTokenStep::onRequestCompleted);
+ m_task->start();
+
+ qDebug() << "Getting XSTS token for" << relyingParty();
+ }
+
+ void XboxSecurityTokenStep::onRequestCompleted()
+ {
+ qCDebug(authCredentials()) << *m_response;
+
+ if (m_request->error() != QNetworkReply::NoError)
+ {
+ qWarning() << "XSTS request error:" << m_request->error();
+
+ if (Net::isApplicationError(m_request->error()))
+ {
+ if (handleStsError())
+ {
+ return;
+ }
+ emit completed(StepResult::SoftFailure,
+ tr("Failed to get %1 authorization: %2").arg(targetName(), m_request->errorString()));
+ }
+ else
+ {
+ emit completed(StepResult::Offline,
+ tr("Failed to get %1 authorization: %2").arg(targetName(), m_request->errorString()));
+ }
+ return;
+ }
+
+ AuthToken token;
+ if (!parseXboxTokenResponse(*m_response, token))
+ {
+ emit completed(StepResult::SoftFailure, tr("Could not parse %1 authorization response.").arg(targetName()));
+ return;
+ }
+
+ // Verify user hash matches
+ const QString responseUhs = token.metadata.value(QStringLiteral("uhs")).toString();
+ if (responseUhs != m_credentials.xboxUserHash())
+ {
+ emit completed(StepResult::SoftFailure, tr("User hash mismatch in %1 authorization.").arg(targetName()));
+ return;
+ }
+
+ targetToken() = token;
+ emit completed(StepResult::Continue, tr("Got %1 authorization.").arg(targetName()));
+ }
+
+ bool XboxSecurityTokenStep::handleStsError()
+ {
+ if (m_request->error() != QNetworkReply::AuthenticationRequiredError)
+ {
+ return false;
+ }
+
+ QJsonParseError jsonError;
+ const auto doc = QJsonDocument::fromJson(*m_response, &jsonError);
+ if (jsonError.error != QJsonParseError::NoError)
+ {
+ emit completed(StepResult::SoftFailure,
+ tr("Cannot parse XSTS error response: %1").arg(jsonError.errorString()));
+ return true;
+ }
+
+ const auto obj = doc.object();
+ const auto errorCode = static_cast<int64_t>(obj.value(QStringLiteral("XErr")).toDouble());
+
+ if (errorCode == 0)
+ {
+ emit completed(StepResult::SoftFailure, tr("XSTS error response missing error code."));
+ return true;
+ }
+
+ // Look up error message
+ for (const auto& [code, message] : kXstsErrors)
+ {
+ if (code == errorCode)
+ {
+ emit completed(StepResult::SoftFailure, tr(message));
+ return true;
+ }
+ }
+
+ emit completed(StepResult::SoftFailure, tr("Unknown XSTS error: %1").arg(errorCode));
+ return true;
+ }
+
+} // namespace projt::minecraft::auth
diff --git a/archived/projt-launcher/launcher/minecraft/auth/steps/XboxSecurityTokenStep.hpp b/archived/projt-launcher/launcher/minecraft/auth/steps/XboxSecurityTokenStep.hpp
new file mode 100644
index 0000000000..911e343728
--- /dev/null
+++ b/archived/projt-launcher/launcher/minecraft/auth/steps/XboxSecurityTokenStep.hpp
@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <memory>
+
+#include "Credentials.hpp"
+#include "Step.hpp"
+#include "net/NetJob.h"
+#include "net/Upload.h"
+
+namespace projt::minecraft::auth
+{
+
+ /**
+ * Target services for XSTS token requests.
+ */
+ enum class XstsTarget
+ {
+ XboxLive, ///< Xbox Live services (for profile fetch)
+ MinecraftServices ///< Minecraft services (for game access)
+ };
+
+ /**
+ * Xbox Security Token Service (XSTS) authorization step.
+ *
+ * Requests an XSTS token for a specific relying party (service).
+ * Two instances are typically used in the auth pipeline:
+ * - One for Xbox Live services (profile)
+ * - One for Minecraft services (game access)
+ *
+ * Endpoint: https://xsts.auth.xboxlive.com/xsts/authorize
+ */
+ class XboxSecurityTokenStep : public Step
+ {
+ Q_OBJECT
+
+ public:
+ /**
+ * Construct an XSTS authorization step.
+ * @param credentials Credentials to populate.
+ * @param target Which service to request authorization for.
+ */
+ explicit XboxSecurityTokenStep(Credentials& credentials, XstsTarget target) noexcept;
+ ~XboxSecurityTokenStep() noexcept override = default;
+
+ [[nodiscard]] QString description() const override;
+
+ public slots:
+ void execute() override;
+
+ private slots:
+ void onRequestCompleted();
+
+ private:
+ [[nodiscard]] QString relyingParty() const;
+ [[nodiscard]] QString targetName() const;
+ [[nodiscard]] AuthToken& targetToken();
+ bool handleStsError();
+
+ XstsTarget m_target;
+
+ std::shared_ptr<QByteArray> m_response;
+ Net::Upload::Ptr m_request;
+ NetJob::Ptr m_task;
+ };
+
+} // namespace projt::minecraft::auth