diff options
Diffstat (limited to 'archived/projt-launcher/launcher/minecraft/auth')
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 |
