aboutsummaryrefslogtreecommitdiff
path: root/api/logic
diff options
context:
space:
mode:
Diffstat (limited to 'api/logic')
-rw-r--r--api/logic/minecraft/auth-msa/BuildConfig.cpp.in9
-rw-r--r--api/logic/minecraft/auth-msa/BuildConfig.h11
-rw-r--r--api/logic/minecraft/auth-msa/CMakeLists.txt28
-rw-r--r--api/logic/minecraft/auth-msa/context.cpp938
-rw-r--r--api/logic/minecraft/auth-msa/context.h128
-rw-r--r--api/logic/minecraft/auth-msa/main.cpp100
-rw-r--r--api/logic/minecraft/auth-msa/mainwindow.cpp97
-rw-r--r--api/logic/minecraft/auth-msa/mainwindow.h34
-rw-r--r--api/logic/minecraft/auth-msa/mainwindow.ui72
9 files changed, 1417 insertions, 0 deletions
diff --git a/api/logic/minecraft/auth-msa/BuildConfig.cpp.in b/api/logic/minecraft/auth-msa/BuildConfig.cpp.in
new file mode 100644
index 00000000..8f470e25
--- /dev/null
+++ b/api/logic/minecraft/auth-msa/BuildConfig.cpp.in
@@ -0,0 +1,9 @@
+#include "BuildConfig.h"
+#include <QObject>
+
+const Config BuildConfig;
+
+Config::Config()
+{
+ CLIENT_ID = "@MOJANGDEMO_CLIENT_ID@";
+}
diff --git a/api/logic/minecraft/auth-msa/BuildConfig.h b/api/logic/minecraft/auth-msa/BuildConfig.h
new file mode 100644
index 00000000..7a01d704
--- /dev/null
+++ b/api/logic/minecraft/auth-msa/BuildConfig.h
@@ -0,0 +1,11 @@
+#pragma once
+#include <QString>
+
+class Config
+{
+public:
+ Config();
+ QString CLIENT_ID;
+};
+
+extern const Config BuildConfig;
diff --git a/api/logic/minecraft/auth-msa/CMakeLists.txt b/api/logic/minecraft/auth-msa/CMakeLists.txt
new file mode 100644
index 00000000..22777d1b
--- /dev/null
+++ b/api/logic/minecraft/auth-msa/CMakeLists.txt
@@ -0,0 +1,28 @@
+find_package(Qt5 COMPONENTS Core Gui Network Widgets REQUIRED)
+
+set(CMAKE_AUTOMOC ON)
+set(CMAKE_AUTOUIC ON)
+set(CMAKE_INCLUDE_CURRENT_DIR ON)
+set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall")
+
+
+set(MOJANGDEMO_CLIENT_ID "" CACHE STRING "Client ID used for OAuth2 in mojangdemo")
+
+configure_file("${CMAKE_CURRENT_SOURCE_DIR}/BuildConfig.cpp.in" "${CMAKE_CURRENT_BINARY_DIR}/BuildConfig.cpp")
+
+set(mojang_SRCS
+ main.cpp
+ context.cpp
+ context.h
+
+ mainwindow.cpp
+ mainwindow.h
+ mainwindow.ui
+
+ ${CMAKE_CURRENT_BINARY_DIR}/BuildConfig.cpp
+ BuildConfig.h
+)
+
+add_executable( mojangdemo ${mojang_SRCS} )
+target_link_libraries( mojangdemo Katabasis Qt5::Gui Qt5::Widgets )
+target_include_directories(mojangdemo PRIVATE logic)
diff --git a/api/logic/minecraft/auth-msa/context.cpp b/api/logic/minecraft/auth-msa/context.cpp
new file mode 100644
index 00000000..d7ecda30
--- /dev/null
+++ b/api/logic/minecraft/auth-msa/context.cpp
@@ -0,0 +1,938 @@
+#include <QNetworkAccessManager>
+#include <QNetworkRequest>
+#include <QNetworkReply>
+#include <QDesktopServices>
+#include <QMetaEnum>
+#include <QDebug>
+
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonArray>
+
+#include <QUrlQuery>
+
+#include <QPixmap>
+#include <QPainter>
+
+#include "context.h"
+#include "katabasis/Globals.h"
+#include "katabasis/StoreQSettings.h"
+#include "katabasis/Requestor.h"
+#include "BuildConfig.h"
+
+using OAuth2 = Katabasis::OAuth2;
+using Requestor = Katabasis::Requestor;
+using Activity = Katabasis::Activity;
+
+Context::Context(QObject *parent) :
+ QObject(parent)
+{
+ mgr = new QNetworkAccessManager(this);
+
+ Katabasis::OAuth2::Options opts;
+ opts.scope = "XboxLive.signin offline_access";
+ opts.clientIdentifier = BuildConfig.CLIENT_ID;
+ opts.authorizationUrl = "https://login.live.com/oauth20_authorize.srf";
+ opts.accessTokenUrl = "https://login.live.com/oauth20_token.srf";
+ opts.listenerPorts = {28562, 28563, 28564, 28565, 28566};
+
+ oauth2 = new OAuth2(opts, m_account.msaToken, this, mgr);
+
+ connect(oauth2, &OAuth2::linkingFailed, this, &Context::onLinkingFailed);
+ connect(oauth2, &OAuth2::linkingSucceeded, this, &Context::onLinkingSucceeded);
+ connect(oauth2, &OAuth2::openBrowser, this, &Context::onOpenBrowser);
+ connect(oauth2, &OAuth2::closeBrowser, this, &Context::onCloseBrowser);
+ connect(oauth2, &OAuth2::activityChanged, this, &Context::onOAuthActivityChanged);
+}
+
+void Context::beginActivity(Activity activity) {
+ if(isBusy()) {
+ throw 0;
+ }
+ activity_ = activity;
+ emit activityChanged(activity_);
+}
+
+void Context::finishActivity() {
+ if(!isBusy()) {
+ throw 0;
+ }
+ activity_ = Katabasis::Activity::Idle;
+ m_account.validity_ = m_account.minecraftProfile.validity;
+ emit activityChanged(activity_);
+}
+
+QString Context::gameToken() {
+ return m_account.minecraftToken.token;
+}
+
+QString Context::userId() {
+ return m_account.minecraftProfile.id;
+}
+
+QString Context::userName() {
+ return m_account.minecraftProfile.name;
+}
+
+bool Context::silentSignIn() {
+ if(isBusy()) {
+ return false;
+ }
+ beginActivity(Activity::Refreshing);
+ if(!oauth2->refresh()) {
+ finishActivity();
+ return false;
+ }
+
+ requestsDone = 0;
+ xboxProfileSucceeded = false;
+ mcAuthSucceeded = false;
+
+ return true;
+}
+
+bool Context::signIn() {
+ if(isBusy()) {
+ return false;
+ }
+
+ requestsDone = 0;
+ xboxProfileSucceeded = false;
+ mcAuthSucceeded = false;
+
+ beginActivity(Activity::LoggingIn);
+ oauth2->unlink();
+ m_account = AccountData();
+ oauth2->link();
+ return true;
+}
+
+bool Context::signOut() {
+ if(isBusy()) {
+ return false;
+ }
+ beginActivity(Activity::LoggingOut);
+ oauth2->unlink();
+ m_account = AccountData();
+ finishActivity();
+ return true;
+}
+
+
+void Context::onOpenBrowser(const QUrl &url) {
+ QDesktopServices::openUrl(url);
+}
+
+void Context::onCloseBrowser() {
+
+}
+
+void Context::onLinkingFailed() {
+ finishActivity();
+}
+
+void Context::onLinkingSucceeded() {
+ auto *o2t = qobject_cast<OAuth2 *>(sender());
+ if (!o2t->linked()) {
+ finishActivity();
+ return;
+ }
+ QVariantMap extraTokens = o2t->extraTokens();
+ if (!extraTokens.isEmpty()) {
+ qDebug() << "Extra tokens in response:";
+ foreach (QString key, extraTokens.keys()) {
+ qDebug() << "\t" << key << ":" << extraTokens.value(key);
+ }
+ }
+ doUserAuth();
+}
+
+void Context::onOAuthActivityChanged(Katabasis::Activity activity) {
+ // respond to activity change here
+}
+
+void Context::doUserAuth() {
+ QString xbox_auth_template = R"XXX(
+{
+ "Properties": {
+ "AuthMethod": "RPS",
+ "SiteName": "user.auth.xboxlive.com",
+ "RpsTicket": "d=%1"
+ },
+ "RelyingParty": "http://auth.xboxlive.com",
+ "TokenType": "JWT"
+}
+)XXX";
+ auto xbox_auth_data = xbox_auth_template.arg(m_account.msaToken.token);
+
+ QNetworkRequest request = QNetworkRequest(QUrl("https://user.auth.xboxlive.com/user/authenticate"));
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader("Accept", "application/json");
+ auto *requestor = new Katabasis::Requestor(mgr, oauth2, this);
+ requestor->setAddAccessTokenInQuery(false);
+
+ connect(requestor, &Requestor::finished, this, &Context::onUserAuthDone);
+ requestor->post(request, xbox_auth_data.toUtf8());
+ qDebug() << "First layer of XBox auth ... commencing.";
+}
+
+namespace {
+bool getDateTime(QJsonValue value, QDateTime & out) {
+ if(!value.isString()) {
+ return false;
+ }
+ out = QDateTime::fromString(value.toString(), Qt::ISODateWithMs);
+ 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;
+}
+
+/*
+{
+ "IssueInstant":"2020-12-07T19:52:08.4463796Z",
+ "NotAfter":"2020-12-21T19:52:08.4463796Z",
+ "Token":"token",
+ "DisplayClaims":{
+ "xui":[
+ {
+ "uhs":"userhash"
+ }
+ ]
+ }
+ }
+*/
+// TODO: handle error responses ...
+/*
+{
+ "Identity":"0",
+ "XErr":2148916238,
+ "Message":"",
+ "Redirect":"https://start.ui.xboxlive.com/AddChildToFamily"
+}
+// 2148916233 = missing XBox account
+// 2148916238 = child account not linked to a family
+*/
+
+bool parseXTokenResponse(QByteArray & data, Katabasis::Token &output) {
+ 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();
+ qDebug() << data;
+ return false;
+ }
+
+ auto obj = doc.object();
+ if(!getDateTime(obj.value("IssueInstant"), output.issueInstant)) {
+ qWarning() << "User IssueInstant is not a timestamp";
+ qDebug() << data;
+ return false;
+ }
+ if(!getDateTime(obj.value("NotAfter"), output.notAfter)) {
+ qWarning() << "User NotAfter is not a timestamp";
+ qDebug() << data;
+ return false;
+ }
+ if(!getString(obj.value("Token"), output.token)) {
+ qWarning() << "User Token is not a timestamp";
+ qDebug() << data;
+ return false;
+ }
+ auto arrayVal = obj.value("DisplayClaims").toObject().value("xui");
+ if(!arrayVal.isArray()) {
+ qWarning() << "Missing xui claims array";
+ qDebug() << data;
+ 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...";
+ qDebug() << data;
+ return false;
+ }
+ output.extra[iter.key()] = claim;
+ }
+
+ break;
+ }
+ if(!foundUHS) {
+ qWarning() << "Missing uhs";
+ qDebug() << data;
+ return false;
+ }
+ output.validity = Katabasis::Validity::Certain;
+ qDebug() << data;
+ return true;
+}
+
+}
+
+void Context::onUserAuthDone(
+ int requestId,
+ QNetworkReply::NetworkError error,
+ QByteArray replyData,
+ QList<QNetworkReply::RawHeaderPair> headers
+) {
+ if (error != QNetworkReply::NoError) {
+ qWarning() << "Reply error:" << error;
+ finishActivity();
+ return;
+ }
+
+ Katabasis::Token temp;
+ if(!parseXTokenResponse(replyData, temp)) {
+ qWarning() << "Could not parse user authentication response...";
+ finishActivity();
+ return;
+ }
+ m_account.userToken = temp;
+
+ doSTSAuthMinecraft();
+ doSTSAuthGeneric();
+}
+/*
+ url = "https://xsts.auth.xboxlive.com/xsts/authorize"
+ headers = {"x-xbl-contract-version": "1"}
+ data = {
+ "RelyingParty": relying_party,
+ "TokenType": "JWT",
+ "Properties": {
+ "UserTokens": [self.user_token.token],
+ "SandboxId": "RETAIL",
+ },
+ }
+*/
+void Context::doSTSAuthMinecraft() {
+ QString xbox_auth_template = R"XXX(
+{
+ "Properties": {
+ "SandboxId": "RETAIL",
+ "UserTokens": [
+ "%1"
+ ]
+ },
+ "RelyingParty": "rp://api.minecraftservices.com/",
+ "TokenType": "JWT"
+}
+)XXX";
+ auto xbox_auth_data = xbox_auth_template.arg(m_account.userToken.token);
+
+ QNetworkRequest request = QNetworkRequest(QUrl("https://xsts.auth.xboxlive.com/xsts/authorize"));
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader("Accept", "application/json");
+ Requestor *requestor = new Requestor(mgr, oauth2, this);
+ requestor->setAddAccessTokenInQuery(false);
+
+ connect(requestor, &Requestor::finished, this, &Context::onSTSAuthMinecraftDone);
+ requestor->post(request, xbox_auth_data.toUtf8());
+ qDebug() << "Second layer of XBox auth ... commencing.";
+}
+
+void Context::onSTSAuthMinecraftDone(
+ int requestId,
+ QNetworkReply::NetworkError error,
+ QByteArray replyData,
+ QList<QNetworkReply::RawHeaderPair> headers
+) {
+ if (error != QNetworkReply::NoError) {
+ qWarning() << "Reply error:" << error;
+ finishActivity();
+ return;
+ }
+
+ Katabasis::Token temp;
+ if(!parseXTokenResponse(replyData, temp)) {
+ qWarning() << "Could not parse authorization response for access to mojang services...";
+ finishActivity();
+ return;
+ }
+
+ if(temp.extra["uhs"] != m_account.userToken.extra["uhs"]) {
+ qWarning() << "Server has changed user hash in the reply... something is wrong. ABORTING";
+ qDebug() << replyData;
+ finishActivity();
+ return;
+ }
+ m_account.mojangservicesToken = temp;
+
+ doMinecraftAuth();
+}
+
+void Context::doSTSAuthGeneric() {
+ QString xbox_auth_template = R"XXX(
+{
+ "Properties": {
+ "SandboxId": "RETAIL",
+ "UserTokens": [
+ "%1"
+ ]
+ },
+ "RelyingParty": "http://xboxlive.com",
+ "TokenType": "JWT"
+}
+)XXX";
+ auto xbox_auth_data = xbox_auth_template.arg(m_account.userToken.token);
+
+ QNetworkRequest request = QNetworkRequest(QUrl("https://xsts.auth.xboxlive.com/xsts/authorize"));
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader("Accept", "application/json");
+ Requestor *requestor = new Requestor(mgr, oauth2, this);
+ requestor->setAddAccessTokenInQuery(false);
+
+ connect(requestor, &Requestor::finished, this, &Context::onSTSAuthGenericDone);
+ requestor->post(request, xbox_auth_data.toUtf8());
+ qDebug() << "Second layer of XBox auth ... commencing.";
+}
+
+void Context::onSTSAuthGenericDone(
+ int requestId,
+ QNetworkReply::NetworkError error,
+ QByteArray replyData,
+ QList<QNetworkReply::RawHeaderPair> headers
+) {
+ if (error != QNetworkReply::NoError) {
+ qWarning() << "Reply error:" << error;
+ finishActivity();
+ return;
+ }
+
+ Katabasis::Token temp;
+ if(!parseXTokenResponse(replyData, temp)) {
+ qWarning() << "Could not parse authorization response for access to xbox API...";
+ finishActivity();
+ return;
+ }
+
+ if(temp.extra["uhs"] != m_account.userToken.extra["uhs"]) {
+ qWarning() << "Server has changed user hash in the reply... something is wrong. ABORTING";
+ qDebug() << replyData;
+ finishActivity();
+ return;
+ }
+ m_account.xboxApiToken = temp;
+
+ doXBoxProfile();
+}
+
+
+void Context::doMinecraftAuth() {
+ QString mc_auth_template = R"XXX(
+{
+ "identityToken": "XBL3.0 x=%1;%2"
+}
+)XXX";
+ auto data = mc_auth_template.arg(m_account.mojangservicesToken.extra["uhs"].toString(), m_account.mojangservicesToken.token);
+
+ QNetworkRequest request = QNetworkRequest(QUrl("https://api.minecraftservices.com/authentication/login_with_xbox"));
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader("Accept", "application/json");
+ Requestor *requestor = new Requestor(mgr, oauth2, this);
+ requestor->setAddAccessTokenInQuery(false);
+
+ connect(requestor, &Requestor::finished, this, &Context::onMinecraftAuthDone);
+ requestor->post(request, data.toUtf8());
+ qDebug() << "Getting Minecraft access token...";
+}
+
+namespace {
+bool parseMojangResponse(QByteArray & data, Katabasis::Token &output) {
+ 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();
+ qDebug() << data;
+ 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";
+ qDebug() << data;
+ 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";
+ qDebug() << data;
+ return false;
+ }
+
+ // TODO: it's a JWT... validate it?
+ if(!getString(obj.value("access_token"), output.token)) {
+ qWarning() << "access_token is not valid";
+ qDebug() << data;
+ return false;
+ }
+ output.validity = Katabasis::Validity::Certain;
+ qDebug() << data;
+ return true;
+}
+}
+
+void Context::onMinecraftAuthDone(
+ int requestId,
+ QNetworkReply::NetworkError error,
+ QByteArray replyData,
+ QList<QNetworkReply::RawHeaderPair> headers
+) {
+ requestsDone++;
+
+ if (error != QNetworkReply::NoError) {
+ qWarning() << "Reply error:" << error;
+ qDebug() << replyData;
+ finishActivity();
+ return;
+ }
+
+ if(!parseMojangResponse(replyData, m_account.minecraftToken)) {
+ qWarning() << "Could not parse login_with_xbox response...";
+ qDebug() << replyData;
+ finishActivity();
+ return;
+ }
+ mcAuthSucceeded = true;
+
+ checkResult();
+}
+
+void Context::doXBoxProfile() {
+ auto url = QUrl("https://profile.xboxlive.com/users/me/profile/settings");
+ QUrlQuery q;
+ q.addQueryItem(
+ "settings",
+ "GameDisplayName,AppDisplayName,AppDisplayPicRaw,GameDisplayPicRaw,"
+ "PublicGamerpic,ShowUserAsAvatar,Gamerscore,Gamertag,ModernGamertag,ModernGamertagSuffix,"
+ "UniqueModernGamertag,AccountTier,TenureLevel,XboxOneRep,"
+ "PreferredColor,Location,Bio,Watermarks,"
+ "RealName,RealNameOverride,IsQuarantined"
+ );
+ url.setQuery(q);
+
+ QNetworkRequest request = QNetworkRequest(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader("Accept", "application/json");
+ request.setRawHeader("x-xbl-contract-version", "3");
+ request.setRawHeader("Authorization", QString("XBL3.0 x=%1;%2").arg(m_account.userToken.extra["uhs"].toString(), m_account.xboxApiToken.token).toUtf8());
+ Requestor *requestor = new Requestor(mgr, oauth2, this);
+ requestor->setAddAccessTokenInQuery(false);
+
+ connect(requestor, &Requestor::finished, this, &Context::onXBoxProfileDone);
+ requestor->get(request);
+ qDebug() << "Getting Xbox profile...";
+}
+
+void Context::onXBoxProfileDone(
+ int requestId,
+ QNetworkReply::NetworkError error,
+ QByteArray replyData,
+ QList<QNetworkReply::RawHeaderPair> headers
+) {
+ requestsDone ++;
+
+ if (error != QNetworkReply::NoError) {
+ qWarning() << "Reply error:" << error;
+ qDebug() << replyData;
+ finishActivity();
+ return;
+ }
+
+ qDebug() << "XBox profile: " << replyData;
+
+ xboxProfileSucceeded = true;
+ checkResult();
+}
+
+void Context::checkResult() {
+ if(requestsDone != 2) {
+ return;
+ }
+ if(mcAuthSucceeded && xboxProfileSucceeded) {
+ doMinecraftProfile();
+ }
+ else {
+ finishActivity();
+ }
+}
+
+namespace {
+bool parseMinecraftProfile(QByteArray & data, MinecraftProfile &output) {
+ 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();
+ qDebug() << data;
+ return false;
+ }
+
+ auto obj = doc.object();
+ if(!getString(obj.value("id"), output.id)) {
+ qWarning() << "minecraft profile id is not a string";
+ qDebug() << data;
+ return false;
+ }
+
+ if(!getString(obj.value("name"), output.name)) {
+ qWarning() << "minecraft profile name is not a string";
+ qDebug() << data;
+ return false;
+ }
+
+ auto skinsArray = obj.value("skins").toArray();
+ for(auto skin: skinsArray) {
+ auto skinObj = skin.toObject();
+ Skin skinOut;
+ if(!getString(skinObj.value("id"), skinOut.id)) {
+ continue;
+ }
+ QString state;
+ if(!getString(skinObj.value("state"), state)) {
+ continue;
+ }
+ if(state != "ACTIVE") {
+ continue;
+ }
+ if(!getString(skinObj.value("url"), skinOut.url)) {
+ continue;
+ }
+ if(!getString(skinObj.value("variant"), skinOut.variant)) {
+ continue;
+ }
+ // we deal with only the active skin
+ output.skin = skinOut;
+ break;
+ }
+ auto capesArray = obj.value("capes").toArray();
+ int i = -1;
+ int currentCape = -1;
+ for(auto cape: capesArray) {
+ i++;
+ 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 = i;
+ }
+ if(!getString(capeObj.value("url"), capeOut.url)) {
+ continue;
+ }
+ if(!getString(capeObj.value("alias"), capeOut.alias)) {
+ continue;
+ }
+
+ // we deal with only the active skin
+ output.capes.push_back(capeOut);
+ }
+ output.currentCape = currentCape;
+ output.validity = Katabasis::Validity::Certain;
+ return true;
+}
+}
+
+void Context::doMinecraftProfile() {
+ auto url = QUrl("https://api.minecraftservices.com/minecraft/profile");
+ QNetworkRequest request = QNetworkRequest(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ // request.setRawHeader("Accept", "application/json");
+ request.setRawHeader("Authorization", QString("Bearer %1").arg(m_account.minecraftToken.token).toUtf8());
+
+ Requestor *requestor = new Requestor(mgr, oauth2, this);
+ requestor->setAddAccessTokenInQuery(false);
+
+ connect(requestor, &Requestor::finished, this, &Context::onMinecraftProfileDone);
+ requestor->get(request);
+}
+
+void Context::onMinecraftProfileDone(int, QNetworkReply::NetworkError error, QByteArray data, QList<QNetworkReply::RawHeaderPair> headers) {
+ qDebug() << data;
+ if (error == QNetworkReply::ContentNotFoundError) {
+ m_account.minecraftProfile = MinecraftProfile();
+ finishActivity();
+ return;
+ }
+ if (error != QNetworkReply::NoError) {
+ finishActivity();
+ return;
+ }
+ if(!parseMinecraftProfile(data, m_account.minecraftProfile)) {
+ m_account.minecraftProfile = MinecraftProfile();
+ finishActivity();
+ return;
+ }
+ doGetSkin();
+}
+
+void Context::doGetSkin() {
+ auto url = QUrl(m_account.minecraftProfile.skin.url);
+ QNetworkRequest request = QNetworkRequest(url);
+ Requestor *requestor = new Requestor(mgr, oauth2, this);
+ requestor->setAddAccessTokenInQuery(false);
+ connect(requestor, &Requestor::finished, this, &Context::onSkinDone);
+ requestor->get(request);
+}
+
+void Context::onSkinDone(int, QNetworkReply::NetworkError error, QByteArray data, QList<QNetworkReply::RawHeaderPair>) {
+ if (error == QNetworkReply::NoError) {
+ m_account.minecraftProfile.skin.data = data;
+ }
+ finishActivity();
+}
+
+namespace {
+void tokenToJSON(QJsonObject &parent, Katabasis::Token t, const char * tokenName) {
+ if(t.validity == Katabasis::Validity::None || !t.persistent) {
+ return;
+ }
+ QJsonObject out;
+ if(t.issueInstant.isValid()) {
+ out["iat"] = QJsonValue(t.issueInstant.toSecsSinceEpoch());
+ }
+
+ if(t.notAfter.isValid()) {
+ out["exp"] = QJsonValue(t.notAfter.toSecsSinceEpoch());
+ }
+
+ if(!t.token.isEmpty()) {
+ out["token"] = QJsonValue(t.token);
+ }
+ if(!t.refresh_token.isEmpty()) {
+ out["refresh_token"] = QJsonValue(t.refresh_token);
+ }
+ if(t.extra.size()) {
+ out["extra"] = QJsonObject::fromVariantMap(t.extra);
+ }
+ if(out.size()) {
+ parent[tokenName] = out;
+ }
+}
+
+Katabasis::Token tokenFromJSON(const QJsonObject &parent, const char * tokenName) {
+ Katabasis::Token out;
+ auto tokenObject = parent.value(tokenName).toObject();
+ if(tokenObject.isEmpty()) {
+ return out;
+ }
+ auto issueInstant = tokenObject.value("iat");
+ if(issueInstant.isDouble()) {
+ out.issueInstant = QDateTime::fromSecsSinceEpoch((int64_t) issueInstant.toDouble());
+ }
+
+ auto notAfter = tokenObject.value("exp");
+ if(notAfter.isDouble()) {
+ out.notAfter = QDateTime::fromSecsSinceEpoch((int64_t) notAfter.toDouble());
+ }
+
+ auto token = tokenObject.value("token");
+ if(token.isString()) {
+ out.token = token.toString();
+ out.validity = Katabasis::Validity::Assumed;
+ }
+
+ auto refresh_token = tokenObject.value("refresh_token");
+ if(refresh_token.isString()) {
+ out.refresh_token = refresh_token.toString();
+ }
+
+ auto extra = tokenObject.value("extra");
+ if(extra.isObject()) {
+ out.extra = extra.toObject().toVariantMap();
+ }
+ return out;
+}
+
+void profileToJSON(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 != -1) {
+ out["cape"] = p.capes[p.currentCape].id;
+ }
+
+ {
+ 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 profileFromJSON(const QJsonObject &parent, const char * tokenName) {
+ MinecraftProfile out;
+ auto tokenObject = parent.value(tokenName).toObject();
+ if(tokenObject.isEmpty()) {
+ return out;
+ }
+ {
+ auto idV = tokenObject.value("id");
+ auto nameV = tokenObject.value("name");
+ if(!idV.isString() || !nameV.isString()) {
+ qWarning() << "mandatory profile attributes are missing or of unexpected type";
+ return MinecraftProfile();
+ }
+ out.name = nameV.toString();
+ out.id = idV.toString();
+ }
+
+ {
+ auto skinV = tokenObject.value("skin");
+ if(!skinV.isObject()) {
+ qWarning() << "skin is missing";
+ return MinecraftProfile();
+ }
+ auto skinObj = skinV.toObject();
+ auto idV = skinObj.value("id");
+ auto urlV = skinObj.value("url");
+ auto variantV = skinObj.value("variant");
+ if(!idV.isString() || !urlV.isString() || !variantV.isString()) {
+ qWarning() << "mandatory skin attributes are missing or of unexpected type";
+ return MinecraftProfile();
+ }
+ out.skin.id = idV.toString();
+ out.skin.url = urlV.toString();
+ out.skin.variant = variantV.toString();
+
+ // data for skin is optional
+ auto dataV = skinObj.value("data");
+ if(dataV.isString()) {
+ // TODO: validate base64
+ out.skin.data = QByteArray::fromBase64(dataV.toString().toLatin1());
+ }
+ else if (!dataV.isUndefined()) {
+ qWarning() << "skin data is something unexpected";
+ return MinecraftProfile();
+ }
+ }
+
+ auto capesV = tokenObject.value("capes");
+ if(!capesV.isArray()) {
+ qWarning() << "capes is not an array!";
+ return MinecraftProfile();
+ }
+ auto capesArray = capesV.toArray();
+ for(auto capeV: capesArray) {
+ if(!capeV.isObject()) {
+ qWarning() << "cape is not an object!";
+ return MinecraftProfile();
+ }
+ auto capeObj = capeV.toObject();
+ auto idV = capeObj.value("id");
+ auto urlV = capeObj.value("url");
+ auto aliasV = capeObj.value("alias");
+ if(!idV.isString() || !urlV.isString() || !aliasV.isString()) {
+ qWarning() << "mandatory skin attributes are missing or of unexpected type";
+ return MinecraftProfile();
+ }
+ Cape cape;
+ cape.id = idV.toString();
+ cape.url = urlV.toString();
+ cape.alias = aliasV.toString();
+
+ // data for cape is optional.
+ auto dataV = capeObj.value("data");
+ if(dataV.isString()) {
+ // TODO: validate base64
+ cape.data = QByteArray::fromBase64(dataV.toString().toLatin1());
+ }
+ else if (!dataV.isUndefined()) {
+ qWarning() << "cape data is something unexpected";
+ return MinecraftProfile();
+ }
+ out.capes.push_back(cape);
+ }
+ out.validity = Katabasis::Validity::Assumed;
+ return out;
+}
+
+}
+
+bool Context::resumeFromState(QByteArray data) {
+ QJsonParseError error;
+ auto doc = QJsonDocument::fromJson(data, &error);
+ if(error.error != QJsonParseError::NoError) {
+ qWarning() << "Failed to parse account data as JSON.";
+ return false;
+ }
+ auto docObject = doc.object();
+ m_account.msaToken = tokenFromJSON(docObject, "msa");
+ m_account.userToken = tokenFromJSON(docObject, "utoken");
+ m_account.xboxApiToken = tokenFromJSON(docObject, "xrp-main");
+ m_account.mojangservicesToken = tokenFromJSON(docObject, "xrp-mc");
+ m_account.minecraftToken = tokenFromJSON(docObject, "ygg");
+
+ m_account.minecraftProfile = profileFromJSON(docObject, "profile");
+
+ m_account.validity_ = m_account.minecraftProfile.validity;
+
+ return true;
+}
+
+QByteArray Context::saveState() {
+ QJsonDocument doc;
+ QJsonObject output;
+ tokenToJSON(output, m_account.msaToken, "msa");
+ tokenToJSON(output, m_account.userToken, "utoken");
+ tokenToJSON(output, m_account.xboxApiToken, "xrp-main");
+ tokenToJSON(output, m_account.mojangservicesToken, "xrp-mc");
+ tokenToJSON(output, m_account.minecraftToken, "ygg");
+ profileToJSON(output, m_account.minecraftProfile, "profile");
+ doc.setObject(output);
+ return doc.toJson(QJsonDocument::Indented);
+}
diff --git a/api/logic/minecraft/auth-msa/context.h b/api/logic/minecraft/auth-msa/context.h
new file mode 100644
index 00000000..f1ac99b8
--- /dev/null
+++ b/api/logic/minecraft/auth-msa/context.h
@@ -0,0 +1,128 @@
+#pragma once
+
+#include <QObject>
+#include <QList>
+#include <QVector>
+#include <QNetworkReply>
+#include <QImage>
+
+#include <katabasis/OAuth2.h>
+
+struct Skin {
+ QString id;
+ QString url;
+ QString variant;
+
+ QByteArray data;
+};
+
+struct Cape {
+ QString id;
+ QString url;
+ QString alias;
+
+ QByteArray data;
+};
+
+struct MinecraftProfile {
+ QString id;
+ QString name;
+ Skin skin;
+ int currentCape = -1;
+ QVector<Cape> capes;
+ Katabasis::Validity validity = Katabasis::Validity::None;
+};
+
+enum class AccountType {
+ MSA,
+ Mojang
+};
+
+struct AccountData {
+ AccountType type = AccountType::MSA;
+
+ Katabasis::Token msaToken;
+ Katabasis::Token userToken;
+ Katabasis::Token xboxApiToken;
+ Katabasis::Token mojangservicesToken;
+ Katabasis::Token minecraftToken;
+
+ MinecraftProfile minecraftProfile;
+ Katabasis::Validity validity_ = Katabasis::Validity::None;
+};
+
+class Context : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit Context(QObject *parent = 0);
+
+ QByteArray saveState();
+ bool resumeFromState(QByteArray data);
+
+ bool isBusy() {
+ return activity_ != Katabasis::Activity::Idle;
+ };
+ Katabasis::Validity validity() {
+ return m_account.validity_;
+ };
+
+ bool signIn();
+ bool silentSignIn();
+ bool signOut();
+
+ QString userName();
+ QString userId();
+ QString gameToken();
+signals:
+ void succeeded();
+ void failed();
+ void activityChanged(Katabasis::Activity activity);
+
+private slots:
+ void onLinkingSucceeded();
+ void onLinkingFailed();
+ void onOpenBrowser(const QUrl &url);
+ void onCloseBrowser();
+ void onOAuthActivityChanged(Katabasis::Activity activity);
+
+private:
+ void doUserAuth();
+ Q_SLOT void onUserAuthDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>);
+
+ void doSTSAuthMinecraft();
+ Q_SLOT void onSTSAuthMinecraftDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>);
+ void doMinecraftAuth();
+ Q_SLOT void onMinecraftAuthDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>);
+
+ void doSTSAuthGeneric();
+ Q_SLOT void onSTSAuthGenericDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>);
+ void doXBoxProfile();
+ Q_SLOT void onXBoxProfileDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>);
+
+ void doMinecraftProfile();
+ Q_SLOT void onMinecraftProfileDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>);
+
+ void doGetSkin();
+ Q_SLOT void onSkinDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>);
+
+ void checkResult();
+
+private:
+ void beginActivity(Katabasis::Activity activity);
+ void finishActivity();
+ void clearTokens();
+
+private:
+ Katabasis::OAuth2 *oauth2 = nullptr;
+
+ int requestsDone = 0;
+ bool xboxProfileSucceeded = false;
+ bool mcAuthSucceeded = false;
+ Katabasis::Activity activity_ = Katabasis::Activity::Idle;
+
+ AccountData m_account;
+
+ QNetworkAccessManager *mgr = nullptr;
+};
diff --git a/api/logic/minecraft/auth-msa/main.cpp b/api/logic/minecraft/auth-msa/main.cpp
new file mode 100644
index 00000000..481e0126
--- /dev/null
+++ b/api/logic/minecraft/auth-msa/main.cpp
@@ -0,0 +1,100 @@
+#include <QApplication>
+#include <QStringList>
+#include <QTimer>
+#include <QDebug>
+#include <QFile>
+#include <QSaveFile>
+
+#include "context.h"
+#include "mainwindow.h"
+
+void myMessageOutput(QtMsgType type, const QMessageLogContext &context, const QString &msg)
+{
+ QByteArray localMsg = msg.toLocal8Bit();
+ const char *file = context.file ? context.file : "";
+ const char *function = context.function ? context.function : "";
+ switch (type) {
+ case QtDebugMsg:
+ fprintf(stderr, "Debug: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function);
+ break;
+ case QtInfoMsg:
+ fprintf(stderr, "Info: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function);
+ break;
+ case QtWarningMsg:
+ fprintf(stderr, "Warning: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function);
+ break;
+ case QtCriticalMsg:
+ fprintf(stderr, "Critical: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function);
+ break;
+ case QtFatalMsg:
+ fprintf(stderr, "Fatal: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function);
+ break;
+ }
+}
+
+class Helper : public QObject {
+ Q_OBJECT
+
+public:
+ Helper(Context * context) : QObject(), context_(context), msg_(QString()) {
+ QFile tokenCache("usercache.dat");
+ if(tokenCache.open(QIODevice::ReadOnly)) {
+ context_->resumeFromState(tokenCache.readAll());
+ }
+ }
+
+public slots:
+ void run() {
+ connect(context_, &Context::activityChanged, this, &Helper::onActivityChanged);
+ context_->silentSignIn();
+ }
+
+ void onFailed() {
+ qDebug() << "Login failed";
+ }
+
+ void onActivityChanged(Katabasis::Activity activity) {
+ if(activity == Katabasis::Activity::Idle) {
+ switch(context_->validity()) {
+ case Katabasis::Validity::None: {
+ // account is gone, remove it.
+ QFile::remove("usercache.dat");
+ }
+ break;
+ case Katabasis::Validity::Assumed: {
+ // this is basically a soft-failed refresh. do nothing.
+ }
+ break;
+ case Katabasis::Validity::Certain: {
+ // stuff got refreshed / signed in. Save.
+ auto data = context_->saveState();
+ QSaveFile tokenCache("usercache.dat");
+ if(tokenCache.open(QIODevice::WriteOnly)) {
+ tokenCache.write(context_->saveState());
+ tokenCache.commit();
+ }
+ }
+ break;
+ }
+ }
+ }
+
+private:
+ Context *context_;
+ QString msg_;
+};
+
+int main(int argc, char *argv[]) {
+ qInstallMessageHandler(myMessageOutput);
+ QApplication a(argc, argv);
+ QCoreApplication::setOrganizationName("MultiMC");
+ QCoreApplication::setApplicationName("MultiMC");
+ Context c;
+ Helper helper(&c);
+ MainWindow window(&c);
+ window.show();
+ QTimer::singleShot(0, &helper, &Helper::run);
+ return a.exec();
+}
+
+#include "main.moc"
diff --git a/api/logic/minecraft/auth-msa/mainwindow.cpp b/api/logic/minecraft/auth-msa/mainwindow.cpp
new file mode 100644
index 00000000..d4e18dc0
--- /dev/null
+++ b/api/logic/minecraft/auth-msa/mainwindow.cpp
@@ -0,0 +1,97 @@
+#include "mainwindow.h"
+#include "ui_mainwindow.h"
+#include <QDebug>
+
+#include <QDesktopServices>
+
+#include "BuildConfig.h"
+
+MainWindow::MainWindow(Context * context, QWidget *parent) :
+ QMainWindow(parent),
+ m_context(context),
+ m_ui(new Ui::MainWindow)
+{
+ m_ui->setupUi(this);
+ connect(m_ui->signInButton_MSA, &QPushButton::clicked, this, &MainWindow::SignInMSAClicked);
+ connect(m_ui->signInButton_Mojang, &QPushButton::clicked, this, &MainWindow::SignInMojangClicked);
+ connect(m_ui->signOutButton, &QPushButton::clicked, this, &MainWindow::SignOutClicked);
+ connect(m_ui->refreshButton, &QPushButton::clicked, this, &MainWindow::RefreshClicked);
+
+ // connect(m_context, &Context::linkingSucceeded, this, &MainWindow::SignInSucceeded);
+ // connect(m_context, &Context::linkingFailed, this, &MainWindow::SignInFailed);
+ connect(m_context, &Context::activityChanged, this, &MainWindow::ActivityChanged);
+ ActivityChanged(Katabasis::Activity::Idle);
+}
+
+MainWindow::~MainWindow() = default;
+
+void MainWindow::ActivityChanged(Katabasis::Activity activity) {
+ switch(activity) {
+ case Katabasis::Activity::Idle: {
+ if(m_context->validity() != Katabasis::Validity::None) {
+ m_ui->signInButton_Mojang->setEnabled(false);
+ m_ui->signInButton_MSA->setEnabled(false);
+ m_ui->signOutButton->setEnabled(true);
+ m_ui->refreshButton->setEnabled(true);
+ m_ui->statusBar->showMessage(QString("Hello %1!").arg(m_context->userName()));
+ }
+ else {
+ m_ui->signInButton_Mojang->setEnabled(true);
+ m_ui->signInButton_MSA->setEnabled(true);
+ m_ui->signOutButton->setEnabled(false);
+ m_ui->refreshButton->setEnabled(false);
+ m_ui->statusBar->showMessage("Press the login button to start.");
+ }
+ }
+ break;
+ case Katabasis::Activity::LoggingIn: {
+ m_ui->signInButton_Mojang->setEnabled(false);
+ m_ui->signInButton_MSA->setEnabled(false);
+ m_ui->signOutButton->setEnabled(false);
+ m_ui->refreshButton->setEnabled(false);
+ m_ui->statusBar->showMessage("Logging in...");
+ }
+ break;
+ case Katabasis::Activity::LoggingOut: {
+ m_ui->signInButton_Mojang->setEnabled(false);
+ m_ui->signInButton_MSA->setEnabled(false);
+ m_ui->signOutButton->setEnabled(false);
+ m_ui->refreshButton->setEnabled(false);
+ m_ui->statusBar->showMessage("Logging out...");
+ }
+ break;
+ case Katabasis::Activity::Refreshing: {
+ m_ui->signInButton_Mojang->setEnabled(false);
+ m_ui->signInButton_MSA->setEnabled(false);
+ m_ui->signOutButton->setEnabled(false);
+ m_ui->refreshButton->setEnabled(false);
+ m_ui->statusBar->showMessage("Refreshing login...");
+ }
+ break;
+ }
+}
+
+void MainWindow::SignInMSAClicked() {
+ qDebug() << "Sign In MSA";
+ // signIn({{"prompt", "select_account"}})
+ // FIXME: wrong. very wrong. this should not be operating on the current context
+ m_context->signIn();
+}
+
+void MainWindow::SignInMojangClicked() {
+ qDebug() << "Sign In Mojang";
+ // signIn({{"prompt", "select_account"}})
+ // FIXME: wrong. very wrong. this should not be operating on the current context
+ m_context->signIn();
+}
+
+
+void MainWindow::SignOutClicked() {
+ qDebug() << "Sign Out";
+ m_context->signOut();
+}
+
+void MainWindow::RefreshClicked() {
+ qDebug() << "Refresh";
+ m_context->silentSignIn();
+}
diff --git a/api/logic/minecraft/auth-msa/mainwindow.h b/api/logic/minecraft/auth-msa/mainwindow.h
new file mode 100644
index 00000000..abde52d8
--- /dev/null
+++ b/api/logic/minecraft/auth-msa/mainwindow.h
@@ -0,0 +1,34 @@
+#pragma once
+
+#include <QMainWindow>
+#include <QScopedPointer>
+#include <QtNetwork>
+#include <katabasis/Bits.h>
+
+#include "context.h"
+
+namespace Ui {
+class MainWindow;
+}
+
+class MainWindow : public QMainWindow {
+ Q_OBJECT
+
+public:
+ explicit MainWindow(Context * context, QWidget *parent = nullptr);
+ ~MainWindow() override;
+
+private slots:
+ void SignInMojangClicked();
+ void SignInMSAClicked();
+
+ void SignOutClicked();
+ void RefreshClicked();
+
+ void ActivityChanged(Katabasis::Activity activity);
+
+private:
+ Context* m_context;
+ QScopedPointer<Ui::MainWindow> m_ui;
+};
+
diff --git a/api/logic/minecraft/auth-msa/mainwindow.ui b/api/logic/minecraft/auth-msa/mainwindow.ui
new file mode 100644
index 00000000..32b34128
--- /dev/null
+++ b/api/logic/minecraft/auth-msa/mainwindow.ui
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MainWindow</class>
+ <widget class="QMainWindow" name="MainWindow">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>1037</width>
+ <height>511</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>SmartMapsClient</string>
+ </property>
+ <property name="dockNestingEnabled">
+ <bool>true</bool>
+ </property>
+ <widget class="QWidget" name="centralWidget">
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="1" column="3">
+ <widget class="QPushButton" name="signInButton_Mojang">
+ <property name="text">
+ <string>SignIn Mojang</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="3">
+ <widget class="Line" name="line">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0" rowspan="7" colspan="3">
+ <widget class="QTreeView" name="accountView"/>
+ </item>
+ <item row="5" column="3">
+ <widget class="QPushButton" name="refreshButton">
+ <property name="text">
+ <string>Refresh</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="3">
+ <widget class="QPushButton" name="signInButton_MSA">
+ <property name="text">
+ <string>SignIn MSA</string>
+ </property>
+ </widget>
+ </item>
+ <item row="6" column="3">
+ <widget class="QPushButton" name="signOutButton">
+ <property name="text">
+ <string>SignOut</string>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="3">
+ <widget class="QPushButton" name="makeActiveButton">
+ <property name="text">
+ <string>Make Active</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QStatusBar" name="statusBar"/>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>