2026-09-01 22:25:23 +03:00
|
|
|
#include "msaauthservice.h"
|
2026-09-03 09:18:48 +03:00
|
|
|
#include "localization.h"
|
2026-09-01 22:25:23 +03:00
|
|
|
|
|
|
|
|
#include <QDateTime>
|
|
|
|
|
#include <QJsonArray>
|
|
|
|
|
#include <QJsonDocument>
|
|
|
|
|
#include <QNetworkAccessManager>
|
|
|
|
|
#include <QNetworkReply>
|
|
|
|
|
#include <QNetworkRequest>
|
|
|
|
|
#include <QUrlQuery>
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
// ── Azure-приложение лаунчера ───────────────────────────────────────────────
|
|
|
|
|
// Временно используется client_id официального лаунчера Mojang: это старое
|
|
|
|
|
// приложение Live Connect, поэтому и конечные точки здесь от login.live.com.
|
|
|
|
|
// При переходе на собственное приложение (портал Azure -> регистрация
|
|
|
|
|
// приложения -> «Allow public client flows») меняются шесть констант ниже:
|
|
|
|
|
// kClientId — id своего приложения;
|
|
|
|
|
// kAuthorizeUrl — https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize
|
|
|
|
|
// kTokenUrl — https://login.microsoftonline.com/consumers/oauth2/v2.0/token
|
|
|
|
|
// kRedirectUri — тот, что зарегистрирован в Azure; для встроенного окна
|
|
|
|
|
// удобен https://login.microsoftonline.com/common/oauth2/nativeclient
|
|
|
|
|
// kScope — XboxLive.signin offline_access
|
|
|
|
|
// kRpsTicketPrefix — "d="; у старого приложения в RpsTicket идёт сырой токен,
|
|
|
|
|
// у нового — с этой приставкой.
|
|
|
|
|
const QString kClientId = QStringLiteral("00000000402b5328");
|
|
|
|
|
const QString kAuthorizeUrl = QStringLiteral("https://login.live.com/oauth20_authorize.srf");
|
|
|
|
|
const QString kTokenUrl = QStringLiteral("https://login.live.com/oauth20_token.srf");
|
|
|
|
|
const QString kRedirectUri = QStringLiteral("https://login.live.com/oauth20_desktop.srf");
|
|
|
|
|
const QString kScope = QStringLiteral("service::user.auth.xboxlive.com::MBI_SSL");
|
|
|
|
|
const QString kRpsTicketPrefix = QString();
|
|
|
|
|
|
|
|
|
|
const QString kXboxAuthUrl = QStringLiteral("https://user.auth.xboxlive.com/user/authenticate");
|
|
|
|
|
const QString kXstsUrl = QStringLiteral("https://xsts.auth.xboxlive.com/xsts/authorize");
|
|
|
|
|
const QString kMinecraftLoginUrl =
|
|
|
|
|
QStringLiteral("https://api.minecraftservices.com/authentication/login_with_xbox");
|
|
|
|
|
const QString kEntitlementsUrl =
|
|
|
|
|
QStringLiteral("https://api.minecraftservices.com/entitlements/mcstore");
|
|
|
|
|
const QString kProfileUrl = QStringLiteral("https://api.minecraftservices.com/minecraft/profile");
|
|
|
|
|
|
|
|
|
|
const int kTransferTimeoutMs = 20000;
|
|
|
|
|
|
|
|
|
|
QString normalizeUuid(const QString &uuid)
|
|
|
|
|
{
|
|
|
|
|
return QString(uuid).remove(u'-');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Первый элемент DisplayClaims.xui — единственный, который отдают и Xbox Live,
|
|
|
|
|
// и XSTS: в нём лежат uhs (хеш пользователя) и xid (он же XUID).
|
|
|
|
|
QJsonObject displayClaim(const QJsonObject &response)
|
|
|
|
|
{
|
|
|
|
|
return response.value(QStringLiteral("DisplayClaims"))
|
|
|
|
|
.toObject()
|
|
|
|
|
.value(QStringLiteral("xui"))
|
|
|
|
|
.toArray()
|
|
|
|
|
.first()
|
|
|
|
|
.toObject();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// XSTS отказывает не текстом, а числовым кодом — без расшифровки пользователь
|
|
|
|
|
// видит просто «401».
|
|
|
|
|
QString describeXstsError(const QJsonObject &response)
|
|
|
|
|
{
|
|
|
|
|
const qint64 code = static_cast<qint64>(response.value(QStringLiteral("XErr")).toDouble());
|
|
|
|
|
switch (code) {
|
|
|
|
|
case 2148916233:
|
2026-09-03 09:18:48 +03:00
|
|
|
return Loc::text("auth.msa.error.noXboxProfile");
|
2026-09-01 22:25:23 +03:00
|
|
|
case 2148916235:
|
2026-09-03 09:18:48 +03:00
|
|
|
return Loc::text("auth.msa.error.countryBlocked");
|
2026-09-01 22:25:23 +03:00
|
|
|
case 2148916236:
|
|
|
|
|
case 2148916237:
|
2026-09-03 09:18:48 +03:00
|
|
|
return Loc::text("auth.msa.error.ageVerification");
|
2026-09-01 22:25:23 +03:00
|
|
|
case 2148916238:
|
2026-09-03 09:18:48 +03:00
|
|
|
return Loc::text("auth.msa.error.childAccount");
|
2026-09-01 22:25:23 +03:00
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
const QString message = response.value(QStringLiteral("Message")).toString();
|
2026-09-03 09:18:48 +03:00
|
|
|
return message.isEmpty() ? Loc::text("auth.msa.error.xboxRejected").arg(code)
|
2026-09-01 22:25:23 +03:00
|
|
|
: message;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString describeOauthError(const QJsonObject &response, int status)
|
|
|
|
|
{
|
|
|
|
|
const QString description = response.value(QStringLiteral("error_description")).toString();
|
|
|
|
|
if (!description.isEmpty())
|
|
|
|
|
return description;
|
|
|
|
|
const QString error = response.value(QStringLiteral("error")).toString();
|
|
|
|
|
if (!error.isEmpty())
|
|
|
|
|
return error;
|
2026-09-03 09:18:48 +03:00
|
|
|
return Loc::text("auth.msa.error.microsoftCode").arg(status);
|
2026-09-01 22:25:23 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
MsaAuthService::MsaAuthService(QObject *parent)
|
|
|
|
|
: QObject(parent)
|
|
|
|
|
, m_network(new QNetworkAccessManager(this))
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString MsaAuthService::clientId()
|
|
|
|
|
{
|
|
|
|
|
return kClientId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QUrl MsaAuthService::authorizationUrl()
|
|
|
|
|
{
|
|
|
|
|
QUrl url(kAuthorizeUrl);
|
|
|
|
|
QUrlQuery query;
|
|
|
|
|
query.addQueryItem(QStringLiteral("client_id"), kClientId);
|
|
|
|
|
query.addQueryItem(QStringLiteral("response_type"), QStringLiteral("code"));
|
|
|
|
|
query.addQueryItem(QStringLiteral("redirect_uri"), kRedirectUri);
|
|
|
|
|
query.addQueryItem(QStringLiteral("scope"), kScope);
|
|
|
|
|
// Без этого Microsoft молча пускает по прошлой сессии браузера, и добавить
|
|
|
|
|
// второй аккаунт становится нечем.
|
|
|
|
|
query.addQueryItem(QStringLiteral("prompt"), QStringLiteral("select_account"));
|
|
|
|
|
url.setQuery(query);
|
|
|
|
|
return url;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool MsaAuthService::matchRedirect(const QUrl &url, QString *code, QString *error)
|
|
|
|
|
{
|
|
|
|
|
const QUrl redirect(kRedirectUri);
|
|
|
|
|
if (url.host().compare(redirect.host(), Qt::CaseInsensitive) != 0
|
|
|
|
|
|| url.path() != redirect.path())
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
|
|
// Код приходит в query, но часть приложений Azure настроена на response_mode
|
|
|
|
|
// с фрагментом — разбираем оба места.
|
|
|
|
|
QUrlQuery query(url.query());
|
|
|
|
|
if (!query.hasQueryItem(QStringLiteral("code")) && !query.hasQueryItem(QStringLiteral("error")))
|
|
|
|
|
query = QUrlQuery(url.fragment());
|
|
|
|
|
|
|
|
|
|
if (code)
|
|
|
|
|
*code = query.queryItemValue(QStringLiteral("code"), QUrl::FullyDecoded);
|
|
|
|
|
if (error) {
|
|
|
|
|
const QString description =
|
|
|
|
|
query.queryItemValue(QStringLiteral("error_description"), QUrl::FullyDecoded);
|
|
|
|
|
*error = description.isEmpty()
|
|
|
|
|
? query.queryItemValue(QStringLiteral("error"), QUrl::FullyDecoded)
|
|
|
|
|
: description;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MsaAuthService::postForm(const QUrl &url, const QByteArray &body, JsonHandler handler)
|
|
|
|
|
{
|
|
|
|
|
QNetworkRequest request{url};
|
|
|
|
|
request.setHeader(QNetworkRequest::ContentTypeHeader,
|
|
|
|
|
QStringLiteral("application/x-www-form-urlencoded"));
|
|
|
|
|
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
|
|
|
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
|
|
|
|
request.setTransferTimeout(kTransferTimeoutMs);
|
|
|
|
|
|
|
|
|
|
QNetworkReply *reply = m_network->post(request, body);
|
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() {
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
|
|
|
QString transportError;
|
|
|
|
|
if (status == 0 && reply->error() != QNetworkReply::NoError)
|
|
|
|
|
transportError = reply->errorString();
|
|
|
|
|
handler(status, QJsonDocument::fromJson(reply->readAll()).object(), transportError);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MsaAuthService::postJson(const QUrl &url,
|
|
|
|
|
const QJsonObject &body,
|
|
|
|
|
const QString &bearer,
|
|
|
|
|
JsonHandler handler)
|
|
|
|
|
{
|
|
|
|
|
QNetworkRequest request{url};
|
|
|
|
|
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
|
|
|
|
request.setRawHeader("Accept", "application/json");
|
|
|
|
|
if (!bearer.isEmpty())
|
|
|
|
|
request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(bearer).toUtf8());
|
|
|
|
|
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
|
|
|
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
|
|
|
|
request.setTransferTimeout(kTransferTimeoutMs);
|
|
|
|
|
|
|
|
|
|
QNetworkReply *reply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
|
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() {
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
|
|
|
QString transportError;
|
|
|
|
|
if (status == 0 && reply->error() != QNetworkReply::NoError)
|
|
|
|
|
transportError = reply->errorString();
|
|
|
|
|
handler(status, QJsonDocument::fromJson(reply->readAll()).object(), transportError);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MsaAuthService::getJson(const QUrl &url, const QString &bearer, JsonHandler handler)
|
|
|
|
|
{
|
|
|
|
|
QNetworkRequest request{url};
|
|
|
|
|
request.setRawHeader("Accept", "application/json");
|
|
|
|
|
if (!bearer.isEmpty())
|
|
|
|
|
request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(bearer).toUtf8());
|
|
|
|
|
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
|
|
|
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
|
|
|
|
request.setTransferTimeout(kTransferTimeoutMs);
|
|
|
|
|
|
|
|
|
|
QNetworkReply *reply = m_network->get(request);
|
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() {
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
|
|
|
QString transportError;
|
|
|
|
|
if (status == 0 && reply->error() != QNetworkReply::NoError)
|
|
|
|
|
transportError = reply->errorString();
|
|
|
|
|
handler(status, QJsonDocument::fromJson(reply->readAll()).object(), transportError);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MsaAuthService::loginWithCode(const QString &code, Callback callback)
|
|
|
|
|
{
|
|
|
|
|
if (code.isEmpty()) {
|
|
|
|
|
AuthResult result;
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.noAuthCode");
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QUrlQuery form;
|
|
|
|
|
form.addQueryItem(QStringLiteral("client_id"), kClientId);
|
|
|
|
|
form.addQueryItem(QStringLiteral("code"), code);
|
|
|
|
|
form.addQueryItem(QStringLiteral("grant_type"), QStringLiteral("authorization_code"));
|
|
|
|
|
form.addQueryItem(QStringLiteral("redirect_uri"), kRedirectUri);
|
|
|
|
|
|
2026-09-03 09:18:48 +03:00
|
|
|
emit progress(Loc::text("auth.msa.progress.exchangingCode"));
|
2026-09-01 22:25:23 +03:00
|
|
|
exchangeToken(form.toString(QUrl::FullyEncoded).toUtf8(), std::move(callback));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MsaAuthService::loginWithRefreshToken(const QString &refreshToken, Callback callback)
|
|
|
|
|
{
|
|
|
|
|
if (refreshToken.isEmpty()) {
|
|
|
|
|
AuthResult result;
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.profileNotLinked");
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QUrlQuery form;
|
|
|
|
|
form.addQueryItem(QStringLiteral("client_id"), kClientId);
|
|
|
|
|
form.addQueryItem(QStringLiteral("refresh_token"), refreshToken);
|
|
|
|
|
form.addQueryItem(QStringLiteral("grant_type"), QStringLiteral("refresh_token"));
|
|
|
|
|
form.addQueryItem(QStringLiteral("redirect_uri"), kRedirectUri);
|
|
|
|
|
form.addQueryItem(QStringLiteral("scope"), kScope);
|
|
|
|
|
|
2026-09-03 09:18:48 +03:00
|
|
|
emit progress(Loc::text("auth.msa.progress.refreshingSession"));
|
2026-09-01 22:25:23 +03:00
|
|
|
exchangeToken(form.toString(QUrl::FullyEncoded).toUtf8(), std::move(callback));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MsaAuthService::exchangeToken(const QByteArray &form, Callback callback)
|
|
|
|
|
{
|
|
|
|
|
postForm(QUrl(kTokenUrl), form,
|
|
|
|
|
[this, callback](int status, const QJsonObject &response, const QString &transportError) {
|
|
|
|
|
if (!transportError.isEmpty()) {
|
|
|
|
|
AuthResult result;
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.microsoftUnreachable").arg(transportError);
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (status != 200) {
|
|
|
|
|
AuthResult result;
|
|
|
|
|
result.error = describeOauthError(response, status);
|
|
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QString accessToken = response.value(QStringLiteral("access_token")).toString();
|
|
|
|
|
// Refresh-токен приходит и при продлении: Microsoft выдаёт новый
|
|
|
|
|
// и обесценивает прежний, поэтому сохранять надо свежий.
|
|
|
|
|
const QString refreshToken = response.value(QStringLiteral("refresh_token")).toString();
|
|
|
|
|
if (accessToken.isEmpty()) {
|
|
|
|
|
AuthResult result;
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.noAccessToken");
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
authenticateXbox(accessToken, refreshToken, callback);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MsaAuthService::authenticateXbox(const QString µsoftToken,
|
|
|
|
|
const QString &refreshToken,
|
|
|
|
|
Callback callback)
|
|
|
|
|
{
|
2026-09-03 09:18:48 +03:00
|
|
|
emit progress(Loc::text("auth.msa.progress.xboxSignIn"));
|
2026-09-01 22:25:23 +03:00
|
|
|
|
|
|
|
|
QJsonObject properties{{QStringLiteral("AuthMethod"), QStringLiteral("RPS")},
|
|
|
|
|
{QStringLiteral("SiteName"), QStringLiteral("user.auth.xboxlive.com")},
|
|
|
|
|
{QStringLiteral("RpsTicket"), kRpsTicketPrefix + microsoftToken}};
|
|
|
|
|
const QJsonObject body{{QStringLiteral("Properties"), properties},
|
|
|
|
|
{QStringLiteral("RelyingParty"), QStringLiteral("http://auth.xboxlive.com")},
|
|
|
|
|
{QStringLiteral("TokenType"), QStringLiteral("JWT")}};
|
|
|
|
|
|
|
|
|
|
postJson(QUrl(kXboxAuthUrl), body, {},
|
|
|
|
|
[this, refreshToken, callback](int status, const QJsonObject &response,
|
|
|
|
|
const QString &transportError) {
|
|
|
|
|
AuthResult result;
|
|
|
|
|
if (!transportError.isEmpty()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.xboxUnreachable").arg(transportError);
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (status != 200) {
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.xboxTokenRejected").arg(status);
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QString token = response.value(QStringLiteral("Token")).toString();
|
|
|
|
|
if (token.isEmpty()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.xboxNoToken");
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
authorizeXsts(token, refreshToken, callback);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MsaAuthService::authorizeXsts(const QString &xblToken,
|
|
|
|
|
const QString &refreshToken,
|
|
|
|
|
Callback callback)
|
|
|
|
|
{
|
2026-09-03 09:18:48 +03:00
|
|
|
emit progress(Loc::text("auth.msa.progress.xsts"));
|
2026-09-01 22:25:23 +03:00
|
|
|
|
|
|
|
|
const QJsonObject properties{{QStringLiteral("SandboxId"), QStringLiteral("RETAIL")},
|
|
|
|
|
{QStringLiteral("UserTokens"), QJsonArray{xblToken}}};
|
|
|
|
|
const QJsonObject body{{QStringLiteral("Properties"), properties},
|
|
|
|
|
{QStringLiteral("RelyingParty"),
|
|
|
|
|
QStringLiteral("rp://api.minecraftservices.com/")},
|
|
|
|
|
{QStringLiteral("TokenType"), QStringLiteral("JWT")}};
|
|
|
|
|
|
|
|
|
|
postJson(QUrl(kXstsUrl), body, {},
|
|
|
|
|
[this, refreshToken, callback](int status, const QJsonObject &response,
|
|
|
|
|
const QString &transportError) {
|
|
|
|
|
AuthResult result;
|
|
|
|
|
if (!transportError.isEmpty()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.xstsUnreachable").arg(transportError);
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (status == 401) {
|
|
|
|
|
result.error = describeXstsError(response);
|
|
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (status != 200) {
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.xstsRejected").arg(status);
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QString token = response.value(QStringLiteral("Token")).toString();
|
|
|
|
|
const QJsonObject claim = displayClaim(response);
|
|
|
|
|
const QString userHash = claim.value(QStringLiteral("uhs")).toString();
|
|
|
|
|
if (token.isEmpty() || userHash.isEmpty()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.xstsNoUser");
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
loginWithXbox(userHash, token, claim.value(QStringLiteral("xid")).toString(),
|
|
|
|
|
refreshToken, callback);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MsaAuthService::loginWithXbox(const QString &userHash,
|
|
|
|
|
const QString &xstsToken,
|
|
|
|
|
const QString &xuid,
|
|
|
|
|
const QString &refreshToken,
|
|
|
|
|
Callback callback)
|
|
|
|
|
{
|
2026-09-03 09:18:48 +03:00
|
|
|
emit progress(Loc::text("auth.msa.progress.minecraftServices"));
|
2026-09-01 22:25:23 +03:00
|
|
|
|
|
|
|
|
const QJsonObject body{{QStringLiteral("identityToken"),
|
|
|
|
|
QStringLiteral("XBL3.0 x=%1;%2").arg(userHash, xstsToken)}};
|
|
|
|
|
|
|
|
|
|
postJson(QUrl(kMinecraftLoginUrl), body, {},
|
|
|
|
|
[this, xuid, refreshToken, callback](int status, const QJsonObject &response,
|
|
|
|
|
const QString &transportError) {
|
|
|
|
|
AuthResult result;
|
|
|
|
|
if (!transportError.isEmpty()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.servicesUnreachable").arg(transportError);
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (status != 200) {
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.servicesRejected").arg(status);
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.accessToken = response.value(QStringLiteral("access_token")).toString();
|
|
|
|
|
if (result.accessToken.isEmpty()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.servicesNoToken");
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const int lifetime = response.value(QStringLiteral("expires_in")).toInt(86400);
|
|
|
|
|
result.expiresAt = QDateTime::currentDateTimeUtc().addSecs(lifetime);
|
|
|
|
|
result.refreshToken = refreshToken;
|
|
|
|
|
result.xuid = xuid;
|
|
|
|
|
result.clientToken = kClientId;
|
|
|
|
|
result.userType = QStringLiteral("msa");
|
|
|
|
|
checkLicense(result, callback);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MsaAuthService::checkLicense(AuthResult result, Callback callback)
|
|
|
|
|
{
|
2026-09-03 09:18:48 +03:00
|
|
|
emit progress(Loc::text("auth.msa.progress.checkingLicence"));
|
2026-09-01 22:25:23 +03:00
|
|
|
|
|
|
|
|
getJson(QUrl(kEntitlementsUrl), result.accessToken,
|
|
|
|
|
[this, result, callback](int status, const QJsonObject &response, const QString &) {
|
|
|
|
|
// Сетевую ошибку здесь не считаем отказом: окончательный ответ
|
|
|
|
|
// про лицензию даёт профиль, а список покупок нужен только
|
|
|
|
|
// затем, чтобы точнее объяснить отказ.
|
|
|
|
|
const bool owns = status == 200
|
|
|
|
|
&& !response.value(QStringLiteral("items")).toArray().isEmpty();
|
|
|
|
|
fetchProfile(result, owns, callback);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MsaAuthService::fetchProfile(AuthResult result, bool storeEntitlement, Callback callback)
|
|
|
|
|
{
|
|
|
|
|
getJson(QUrl(kProfileUrl), result.accessToken,
|
|
|
|
|
[result, storeEntitlement, callback](int status, const QJsonObject &response,
|
|
|
|
|
const QString &transportError) mutable {
|
|
|
|
|
if (!transportError.isEmpty()) {
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.servicesUnreachable")
|
2026-09-01 22:25:23 +03:00
|
|
|
.arg(transportError);
|
|
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 404 здесь — штатный ответ для аккаунта, на котором нет копии
|
|
|
|
|
// игры: профиль игрока создаётся только вместе с покупкой.
|
|
|
|
|
if (status == 404 || status == 401) {
|
|
|
|
|
result.licenseMissing = true;
|
|
|
|
|
result.error =
|
|
|
|
|
storeEntitlement
|
2026-09-03 09:18:48 +03:00
|
|
|
? Loc::text("auth.msa.error.noProfileYet")
|
|
|
|
|
: Loc::text("auth.msa.error.noLicence");
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (status != 200) {
|
|
|
|
|
result.error =
|
2026-09-03 09:18:48 +03:00
|
|
|
Loc::text("auth.msa.error.profileFetchFailed").arg(status);
|
2026-09-01 22:25:23 +03:00
|
|
|
callback(result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.playerName = response.value(QStringLiteral("name")).toString();
|
|
|
|
|
result.uuid = normalizeUuid(response.value(QStringLiteral("id")).toString());
|
|
|
|
|
result.ok = !result.playerName.isEmpty() && !result.uuid.isEmpty();
|
|
|
|
|
if (!result.ok) {
|
|
|
|
|
result.licenseMissing = true;
|
2026-09-03 09:18:48 +03:00
|
|
|
result.error = Loc::text("auth.msa.error.servicesNoProfile");
|
2026-09-01 22:25:23 +03:00
|
|
|
}
|
|
|
|
|
callback(result);
|
|
|
|
|
});
|
|
|
|
|
}
|