aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt1
-rw-r--r--COPYING.md70
-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
-rw-r--r--libraries/katabasis/.gitignore2
-rw-r--r--libraries/katabasis/CMakeLists.txt60
-rw-r--r--libraries/katabasis/LICENSE23
-rw-r--r--libraries/katabasis/README.md36
-rw-r--r--libraries/katabasis/acknowledgements.md110
-rw-r--r--libraries/katabasis/include/katabasis/Bits.h33
-rw-r--r--libraries/katabasis/include/katabasis/Globals.h59
-rw-r--r--libraries/katabasis/include/katabasis/OAuth2.h233
-rw-r--r--libraries/katabasis/include/katabasis/PollServer.h48
-rw-r--r--libraries/katabasis/include/katabasis/Reply.h60
-rw-r--r--libraries/katabasis/include/katabasis/ReplyServer.h53
-rw-r--r--libraries/katabasis/include/katabasis/RequestParameter.h15
-rw-r--r--libraries/katabasis/include/katabasis/Requestor.h116
-rw-r--r--libraries/katabasis/src/JsonResponse.cpp26
-rw-r--r--libraries/katabasis/src/JsonResponse.h12
-rw-r--r--libraries/katabasis/src/OAuth2.cpp668
-rw-r--r--libraries/katabasis/src/PollServer.cpp123
-rw-r--r--libraries/katabasis/src/Reply.cpp62
-rwxr-xr-xlibraries/katabasis/src/ReplyServer.cpp182
-rw-r--r--libraries/katabasis/src/Requestor.cpp304
31 files changed, 3691 insertions, 22 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 5e3d6cea..be6f7dfe 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -278,6 +278,7 @@ add_subdirectory(libraries/LocalPeer) # fork of a library from Qt solutions
add_subdirectory(libraries/classparser) # google analytics library
add_subdirectory(libraries/optional-bare)
add_subdirectory(libraries/tomlc99) # toml parser
+add_subdirectory(libraries/katabasis) # An OAuth2 library that tried to do too much
############################### Built Artifacts ###############################
diff --git a/COPYING.md b/COPYING.md
index caa4bed5..4c19bbc2 100644
--- a/COPYING.md
+++ b/COPYING.md
@@ -254,25 +254,51 @@
# tomlc99
- MIT License
-
- Copyright (c) 2017 CK Tan
- https://github.com/cktan/tomlc99
-
- Permission is hereby granted, free of charge, to any person obtaining a copy
- of this software and associated documentation files (the "Software"), to deal
- in the Software without restriction, including without limitation the rights
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- copies of the Software, and to permit persons to whom the Software is
- furnished to do so, subject to the following conditions:
-
- The above copyright notice and this permission notice shall be included in all
- copies or substantial portions of the Software.
-
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- SOFTWARE.
+ MIT License
+
+ Copyright (c) 2017 CK Tan
+ https://github.com/cktan/tomlc99
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+
+# O2 (Katabasis fork)
+
+ Copyright (c) 2012, Akos Polster
+ All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
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>
diff --git a/libraries/katabasis/.gitignore b/libraries/katabasis/.gitignore
new file mode 100644
index 00000000..35e189c5
--- /dev/null
+++ b/libraries/katabasis/.gitignore
@@ -0,0 +1,2 @@
+build/
+*.kdev4
diff --git a/libraries/katabasis/CMakeLists.txt b/libraries/katabasis/CMakeLists.txt
new file mode 100644
index 00000000..7bbe1a8b
--- /dev/null
+++ b/libraries/katabasis/CMakeLists.txt
@@ -0,0 +1,60 @@
+cmake_minimum_required(VERSION 3.20)
+
+string(COMPARE EQUAL "${CMAKE_SOURCE_DIR}" "${CMAKE_BUILD_DIR}" IS_IN_SOURCE_BUILD)
+if(IS_IN_SOURCE_BUILD)
+ message(FATAL_ERROR "You are building Katabasis in-source. Please separate the build tree from the source tree.")
+endif()
+
+if (CMAKE_SYSTEM_NAME STREQUAL "Linux")
+ if(CMAKE_HOST_SYSTEM_VERSION MATCHES ".*[Mm]icrosoft.*" OR
+ CMAKE_HOST_SYSTEM_VERSION MATCHES ".*WSL.*"
+ )
+ message(FATAL_ERROR "Building Katabasis is not supported in Linux-on-Windows distributions. Use a real Linux machine, not a fraudulent one.")
+ endif()
+endif()
+
+project(Katabasis)
+enable_testing()
+
+set(CMAKE_AUTOMOC ON)
+set(CMAKE_INCLUDE_CURRENT_DIR ON)
+
+set(CMAKE_CXX_STANDARD_REQUIRED true)
+set(CMAKE_C_STANDARD_REQUIRED true)
+set(CMAKE_CXX_STANDARD 11)
+set(CMAKE_C_STANDARD 11)
+
+find_package(Qt5 COMPONENTS Core Network REQUIRED)
+
+set( katabasis_PRIVATE
+ src/OAuth2.cpp
+
+ src/JsonResponse.cpp
+ src/JsonResponse.h
+ src/PollServer.cpp
+ src/Reply.cpp
+ src/ReplyServer.cpp
+ src/Requestor.cpp
+)
+
+set( katabasis_PUBLIC
+ include/katabasis/OAuth2.h
+
+ include/katabasis/Globals.h
+ include/katabasis/PollServer.h
+ include/katabasis/Reply.h
+ include/katabasis/ReplyServer.h
+
+ include/katabasis/Requestor.h
+ include/katabasis/RequestParameter.h
+)
+
+add_library( Katabasis STATIC ${katabasis_PRIVATE} ${katabasis_PUBLIC} )
+target_link_libraries(Katabasis Qt5::Core Qt5::Network)
+
+# needed for statically linked Katabasis in shared libs on x86_64
+set_target_properties(Katabasis
+ PROPERTIES POSITION_INDEPENDENT_CODE TRUE
+)
+
+target_include_directories(Katabasis PUBLIC include PRIVATE src include/katabasis)
diff --git a/libraries/katabasis/LICENSE b/libraries/katabasis/LICENSE
new file mode 100644
index 00000000..9ac8d42f
--- /dev/null
+++ b/libraries/katabasis/LICENSE
@@ -0,0 +1,23 @@
+Copyright (c) 2012, Akos Polster
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/libraries/katabasis/README.md b/libraries/katabasis/README.md
new file mode 100644
index 00000000..a4dc0994
--- /dev/null
+++ b/libraries/katabasis/README.md
@@ -0,0 +1,36 @@
+# Katabasis - MS-flavoerd OAuth for Qt, derived from the O2 library
+
+This library's sole purpose is to make interacting with MSA and various MSA and XBox authenticated services less painful.
+
+It may be possible to backport some of the changes to O2 in the future, but for the sake of going fast, all compatibility concerns have been ignored.
+
+[You can find the original library's git repository here.](https://github.com/pipacs/o2)
+
+Notes to contributors:
+
+ * Please follow the coding style of the existing source, where reasonable
+ * Code contributions are released under Simplified BSD License, as specified in LICENSE. Do not contribute if this license does not suit your code
+ * If you are interested in working on this, come to the MultiMC Discord server and talk first
+
+## Installation
+
+Clone the Github repository, integrate the it into your CMake build system.
+
+The library is static only, dynamic linking and system-wide installation are out of scope and undesirable.
+
+## Usage
+
+At this stage, don't, unless you want to help with the library itself.
+
+This is an experimental fork of the O2 library and is undergoing a big design/architecture shift in order to support different features:
+
+* Multiple accounts
+* Multi-stage authentication/authorization schemes
+* Tighter control over token chains and their storage
+* Talking to complex APIs and individually authorized microservices
+* Token lifetime management, 'offline mode' and resilience in face of network failures
+* Token and claims/entitlements validation
+* Caching of some API results
+* XBox magic
+* Mojang magic
+* Generally, magic that you would spend weeks on researching while getting confused by contradictory/incomplete documentation (if any is available)
diff --git a/libraries/katabasis/acknowledgements.md b/libraries/katabasis/acknowledgements.md
new file mode 100644
index 00000000..c1c8a3d4
--- /dev/null
+++ b/libraries/katabasis/acknowledgements.md
@@ -0,0 +1,110 @@
+# O2 library by Akos Polster and contributors
+
+[The origin of this fork.](https://github.com/pipacs/o2)
+
+> Copyright (c) 2012, Akos Polster
+> All rights reserved.
+>
+> Redistribution and use in source and binary forms, with or without
+> modification, are permitted provided that the following conditions are met:
+>
+> * Redistributions of source code must retain the above copyright notice, this
+> list of conditions and the following disclaimer.
+>
+> * Redistributions in binary form must reproduce the above copyright notice,
+> this list of conditions and the following disclaimer in the documentation
+> and/or other materials provided with the distribution.
+>
+> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+> AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+> IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+> DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+> FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+> DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+> SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+> CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+> OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+> OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+# SimpleCrypt by Andre Somers
+
+Cryptographic methods for Qt.
+
+> Copyright (c) 2011, Andre Somers
+> All rights reserved.
+>
+> Redistribution and use in source and binary forms, with or without
+> modification, are permitted provided that the following conditions are met:
+>
+> * Redistributions of source code must retain the above copyright
+> notice, this list of conditions and the following disclaimer.
+> * Redistributions in binary form must reproduce the above copyright
+> notice, this list of conditions and the following disclaimer in the
+> documentation and/or other materials provided with the distribution.
+> * Neither the name of the Rathenau Instituut, Andre Somers nor the
+> names of its contributors may be used to endorse or promote products
+> derived from this software without specific prior written permission.
+>
+> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+> ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+> WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+> DISCLAIMED. IN NO EVENT SHALL ANDRE SOMERS BE LIABLE FOR ANY
+> DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+> (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+> LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+> ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+> (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+> SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+# Mandeep Sandhu <mandeepsandhu.chd@gmail.com>
+
+Configurable settings storage, Twitter XAuth specialization, new demos, cleanups.
+
+> "Hi Akos,
+>
+> I'm writing this mail to confirm that my contributions to the O2 library, available here https://github.com/pipacs/o2, can be freely distributed according to the project's license (as shown in the LICENSE file).
+>
+> Regards,
+> -mandeep"
+
+# Sergey Gavrushkin <https://github.com/ncux>
+
+FreshBooks specialization
+
+# Theofilos Intzoglou <https://github.com/parapente>
+
+Hubic specialization
+
+# Dimitar
+
+SurveyMonkey specialization
+
+# David Brooks <https://github.com/dbrnz>
+
+CMake related fixes and improvements.
+
+# Lukas Vogel <https://github.com/lukedirtwalker>
+
+Spotify support
+
+# Alan Garny <https://github.com/agarny>
+
+Windows DLL build support
+
+# MartinMikita <https://github.com/MartinMikita>
+
+Bug fixes
+
+# Larry Shaffer <https://github.com/dakcarto>
+
+Versioning, shared lib, install target and header support
+
+# Gilmanov Ildar <https://github.com/gilmanov-ildar>
+
+Bug fixes, support for ```qml``` module
+
+# Fabian Vogt <https://github.com/Vogtinator>
+
+Bug fixes, support for building without Qt keywords enabled
+
diff --git a/libraries/katabasis/include/katabasis/Bits.h b/libraries/katabasis/include/katabasis/Bits.h
new file mode 100644
index 00000000..3fd2f530
--- /dev/null
+++ b/libraries/katabasis/include/katabasis/Bits.h
@@ -0,0 +1,33 @@
+#pragma once
+
+#include <QString>
+#include <QDateTime>
+#include <QMap>
+#include <QVariantMap>
+
+namespace Katabasis {
+enum class Activity {
+ Idle,
+ LoggingIn,
+ LoggingOut,
+ Refreshing
+};
+
+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;
+};
+
+}
diff --git a/libraries/katabasis/include/katabasis/Globals.h b/libraries/katabasis/include/katabasis/Globals.h
new file mode 100644
index 00000000..512745d3
--- /dev/null
+++ b/libraries/katabasis/include/katabasis/Globals.h
@@ -0,0 +1,59 @@
+#pragma once
+
+namespace Katabasis {
+
+// Common constants
+const char ENCRYPTION_KEY[] = "12345678";
+const char MIME_TYPE_XFORM[] = "application/x-www-form-urlencoded";
+const char MIME_TYPE_JSON[] = "application/json";
+
+// OAuth 1/1.1 Request Parameters
+const char OAUTH_CALLBACK[] = "oauth_callback";
+const char OAUTH_CONSUMER_KEY[] = "oauth_consumer_key";
+const char OAUTH_NONCE[] = "oauth_nonce";
+const char OAUTH_SIGNATURE[] = "oauth_signature";
+const char OAUTH_SIGNATURE_METHOD[] = "oauth_signature_method";
+const char OAUTH_TIMESTAMP[] = "oauth_timestamp";
+const char OAUTH_VERSION[] = "oauth_version";
+// OAuth 1/1.1 Response Parameters
+const char OAUTH_TOKEN[] = "oauth_token";
+const char OAUTH_TOKEN_SECRET[] = "oauth_token_secret";
+const char OAUTH_CALLBACK_CONFIRMED[] = "oauth_callback_confirmed";
+const char OAUTH_VERFIER[] = "oauth_verifier";
+
+// OAuth 2 Request Parameters
+const char OAUTH2_RESPONSE_TYPE[] = "response_type";
+const char OAUTH2_CLIENT_ID[] = "client_id";
+const char OAUTH2_CLIENT_SECRET[] = "client_secret";
+const char OAUTH2_USERNAME[] = "username";
+const char OAUTH2_PASSWORD[] = "password";
+const char OAUTH2_REDIRECT_URI[] = "redirect_uri";
+const char OAUTH2_SCOPE[] = "scope";
+const char OAUTH2_GRANT_TYPE_CODE[] = "code";
+const char OAUTH2_GRANT_TYPE_TOKEN[] = "token";
+const char OAUTH2_GRANT_TYPE_PASSWORD[] = "password";
+const char OAUTH2_GRANT_TYPE_DEVICE[] = "urn:ietf:params:oauth:grant-type:device_code";
+const char OAUTH2_GRANT_TYPE[] = "grant_type";
+const char OAUTH2_API_KEY[] = "api_key";
+const char OAUTH2_STATE[] = "state";
+const char OAUTH2_CODE[] = "code";
+
+// OAuth 2 Response Parameters
+const char OAUTH2_ACCESS_TOKEN[] = "access_token";
+const char OAUTH2_REFRESH_TOKEN[] = "refresh_token";
+const char OAUTH2_EXPIRES_IN[] = "expires_in";
+const char OAUTH2_DEVICE_CODE[] = "device_code";
+const char OAUTH2_USER_CODE[] = "user_code";
+const char OAUTH2_VERIFICATION_URI[] = "verification_uri";
+const char OAUTH2_VERIFICATION_URL[] = "verification_url"; // Google sign-in
+const char OAUTH2_VERIFICATION_URI_COMPLETE[] = "verification_uri_complete";
+const char OAUTH2_INTERVAL[] = "interval";
+
+// Parameter values
+const char AUTHORIZATION_CODE[] = "authorization_code";
+
+// Standard HTTP headers
+const char HTTP_HTTP_HEADER[] = "HTTP";
+const char HTTP_AUTHORIZATION_HEADER[] = "Authorization";
+
+}
diff --git a/libraries/katabasis/include/katabasis/OAuth2.h b/libraries/katabasis/include/katabasis/OAuth2.h
new file mode 100644
index 00000000..4361691c
--- /dev/null
+++ b/libraries/katabasis/include/katabasis/OAuth2.h
@@ -0,0 +1,233 @@
+#pragma once
+
+#include <QNetworkAccessManager>
+#include <QNetworkRequest>
+#include <QNetworkReply>
+#include <QPair>
+
+#include "Reply.h"
+#include "RequestParameter.h"
+#include "Bits.h"
+
+namespace Katabasis {
+
+class ReplyServer;
+class PollServer;
+
+
+/*
+ * FIXME: this is not as simple as it should be. it squishes 4 different grant flows into one big ball of mud
+ * This serves no practical purpose and simply makes the code less readable / maintainable.
+ *
+ * Therefore: Split this into the 4 different OAuth2 flows that people can use as authentication steps. Write tests/examples for all of them.
+ */
+
+/// Simple OAuth2 authenticator.
+class OAuth2: public QObject
+{
+ Q_OBJECT
+public:
+ Q_ENUMS(GrantFlow)
+
+public:
+
+ struct Options {
+ QString userAgent = QStringLiteral("Katabasis/1.0");
+ QString redirectionUrl = QStringLiteral("http://localhost:%1");
+ QString responseType = QStringLiteral("code");
+ QString scope;
+ QString clientIdentifier;
+ QString clientSecret;
+ QUrl authorizationUrl;
+ QUrl accessTokenUrl;
+ QVector<quint16> listenerPorts = { 0 };
+ };
+
+ /// Authorization flow types.
+ enum GrantFlow {
+ GrantFlowAuthorizationCode, ///< @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.1
+ GrantFlowImplicit, ///< @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.2
+ GrantFlowResourceOwnerPasswordCredentials,
+ GrantFlowDevice ///< @see https://tools.ietf.org/html/rfc8628#section-1
+ };
+
+ /// Authorization flow.
+ GrantFlow grantFlow();
+ void setGrantFlow(GrantFlow value);
+
+public:
+ /// Are we authenticated?
+ bool linked();
+
+ /// Authentication token.
+ QString token();
+
+ /// Provider-specific extra tokens, available after a successful authentication
+ QVariantMap extraTokens();
+
+ /// Page content on local host after successful oauth.
+ /// Provide it in case you do not want to close the browser, but display something
+ QByteArray replyContent() const;
+ void setReplyContent(const QByteArray &value);
+
+public:
+
+ // TODO: remove
+ /// Resource owner username.
+ /// instances with the same (username, password) share the same "linked" and "token" properties.
+ QString username();
+ void setUsername(const QString &value);
+
+ // TODO: remove
+ /// Resource owner password.
+ /// instances with the same (username, password) share the same "linked" and "token" properties.
+ QString password();
+ void setPassword(const QString &value);
+
+ // TODO: remove
+ /// API key.
+ QString apiKey();
+ void setApiKey(const QString &value);
+
+ // TODO: remove
+ /// Allow ignoring SSL errors?
+ /// E.g. SurveyMonkey fails on Mac due to SSL error. Ignoring the error circumvents the problem
+ bool ignoreSslErrors();
+ void setIgnoreSslErrors(bool ignoreSslErrors);
+
+ // TODO: put in `Options`
+ /// User-defined extra parameters to append to request URL
+ QVariantMap extraRequestParams();
+ void setExtraRequestParams(const QVariantMap &value);
+
+ // TODO: split up the class into multiple, each implementing one OAuth2 flow
+ /// Grant type (if non-standard)
+ QString grantType();
+ void setGrantType(const QString &value);
+
+public:
+ /// Constructor.
+ /// @param parent Parent object.
+ explicit OAuth2(Options & opts, Token & token, QObject *parent = 0, QNetworkAccessManager *manager = 0);
+
+ /// Get refresh token.
+ QString refreshToken();
+
+ /// Get token expiration time
+ QDateTime expires();
+
+public slots:
+ /// Authenticate.
+ virtual void link();
+
+ /// De-authenticate.
+ virtual void unlink();
+
+ /// Refresh token.
+ bool refresh();
+
+ /// Handle situation where reply server has opted to close its connection
+ void serverHasClosed(bool paramsfound = false);
+
+signals:
+ /// Emitted when a token refresh has been completed or failed.
+ void refreshFinished(QNetworkReply::NetworkError error);
+
+ /// Emitted when client needs to open a web browser window, with the given URL.
+ void openBrowser(const QUrl &url);
+
+ /// Emitted when client can close the browser window.
+ void closeBrowser();
+
+ /// Emitted when client needs to show a verification uri and user code
+ void showVerificationUriAndCode(const QUrl &uri, const QString &code);
+
+ /// Emitted when authentication/deauthentication succeeded.
+ void linkingSucceeded();
+
+ /// Emitted when authentication/deauthentication failed.
+ void linkingFailed();
+
+ void activityChanged(Activity activity);
+
+public slots:
+ /// Handle verification response.
+ virtual void onVerificationReceived(QMap<QString, QString>);
+
+protected slots:
+ /// Handle completion of a token request.
+ virtual void onTokenReplyFinished();
+
+ /// Handle failure of a token request.
+ virtual void onTokenReplyError(QNetworkReply::NetworkError error);
+
+ /// Handle completion of a refresh request.
+ virtual void onRefreshFinished();
+
+ /// Handle failure of a refresh request.
+ virtual void onRefreshError(QNetworkReply::NetworkError error);
+
+ /// Handle completion of a Device Authorization Request
+ virtual void onDeviceAuthReplyFinished();
+
+protected:
+ /// Build HTTP request body.
+ QByteArray buildRequestBody(const QMap<QString, QString> &parameters);
+
+ /// Set refresh token.
+ void setRefreshToken(const QString &v);
+
+ /// Set token expiration time.
+ void setExpires(QDateTime v);
+
+ /// Start polling authorization server
+ void startPollServer(const QVariantMap &params);
+
+ /// Set authentication token.
+ void setToken(const QString &v);
+
+ /// Set the linked state
+ void setLinked(bool v);
+
+ /// Set extra tokens found in OAuth response
+ void setExtraTokens(QVariantMap extraTokens);
+
+ /// Set local reply server
+ void setReplyServer(ReplyServer *server);
+
+ ReplyServer * replyServer() const;
+
+ /// Set local poll server
+ void setPollServer(PollServer *server);
+
+ PollServer * pollServer() const;
+
+ void updateActivity(Activity activity);
+
+protected:
+ QString username_;
+ QString password_;
+
+ Options options_;
+
+ QVariantMap extraReqParams_;
+ QString apiKey_;
+ QNetworkAccessManager *manager_ = nullptr;
+ ReplyList timedReplies_;
+ GrantFlow grantFlow_;
+ QString grantType_;
+
+protected:
+ QString redirectUri_;
+ Token &token_;
+
+ // this should be part of the reply server impl
+ QByteArray replyContent_;
+
+private:
+ ReplyServer *replyServer_ = nullptr;
+ PollServer *pollServer_ = nullptr;
+ Activity activity_ = Activity::Idle;
+};
+
+}
diff --git a/libraries/katabasis/include/katabasis/PollServer.h b/libraries/katabasis/include/katabasis/PollServer.h
new file mode 100644
index 00000000..77103867
--- /dev/null
+++ b/libraries/katabasis/include/katabasis/PollServer.h
@@ -0,0 +1,48 @@
+#pragma once
+
+#include <QByteArray>
+#include <QMap>
+#include <QNetworkRequest>
+#include <QObject>
+#include <QString>
+#include <QTimer>
+
+class QNetworkAccessManager;
+
+namespace Katabasis {
+
+/// Poll an authorization server for token
+class PollServer : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit PollServer(QNetworkAccessManager * manager, const QNetworkRequest &request, const QByteArray & payload, int expiresIn, QObject *parent = 0);
+
+ /// Seconds to wait between polling requests
+ Q_PROPERTY(int interval READ interval WRITE setInterval)
+ int interval() const;
+ void setInterval(int interval);
+
+signals:
+ void verificationReceived(QMap<QString, QString>);
+ void serverClosed(bool); // whether it has found parameters
+
+public slots:
+ void startPolling();
+
+protected slots:
+ void onPollTimeout();
+ void onExpiration();
+ void onReplyFinished();
+
+protected:
+ QNetworkAccessManager *manager_;
+ const QNetworkRequest request_;
+ const QByteArray payload_;
+ const int expiresIn_;
+ QTimer expirationTimer;
+ QTimer pollTimer;
+};
+
+}
diff --git a/libraries/katabasis/include/katabasis/Reply.h b/libraries/katabasis/include/katabasis/Reply.h
new file mode 100644
index 00000000..3af1d49f
--- /dev/null
+++ b/libraries/katabasis/include/katabasis/Reply.h
@@ -0,0 +1,60 @@
+#pragma once
+
+#include <QList>
+#include <QTimer>
+#include <QNetworkRequest>
+#include <QNetworkReply>
+#include <QNetworkAccessManager>
+#include <QByteArray>
+
+namespace Katabasis {
+
+/// A network request/reply pair that can time out.
+class Reply: public QTimer {
+ Q_OBJECT
+
+public:
+ Reply(QNetworkReply *reply, int timeOut = 60 * 1000, QObject *parent = 0);
+
+signals:
+ void error(QNetworkReply::NetworkError);
+
+public slots:
+ /// When time out occurs, the QNetworkReply's error() signal is triggered.
+ void onTimeOut();
+
+public:
+ QNetworkReply *reply;
+};
+
+/// List of O2Replies.
+class ReplyList {
+public:
+ ReplyList() { ignoreSslErrors_ = false; }
+
+ /// Destructor.
+ /// Deletes all O2Reply instances in the list.
+ virtual ~ReplyList();
+
+ /// Create a new O2Reply from a QNetworkReply, and add it to this list.
+ void add(QNetworkReply *reply);
+
+ /// Add an O2Reply to the list, while taking ownership of it.
+ void add(Reply *reply);
+
+ /// Remove item from the list that corresponds to a QNetworkReply.
+ void remove(QNetworkReply *reply);
+
+ /// Find an O2Reply in the list, corresponding to a QNetworkReply.
+ /// @return Matching O2Reply or NULL.
+ Reply *find(QNetworkReply *reply);
+
+ bool ignoreSslErrors();
+ void setIgnoreSslErrors(bool ignoreSslErrors);
+
+protected:
+ QList<Reply *> replies_;
+ bool ignoreSslErrors_;
+};
+
+}
diff --git a/libraries/katabasis/include/katabasis/ReplyServer.h b/libraries/katabasis/include/katabasis/ReplyServer.h
new file mode 100644
index 00000000..bf47df69
--- /dev/null
+++ b/libraries/katabasis/include/katabasis/ReplyServer.h
@@ -0,0 +1,53 @@
+#pragma once
+
+#include <QTcpServer>
+#include <QMap>
+#include <QByteArray>
+#include <QString>
+
+namespace Katabasis {
+
+/// HTTP server to process authentication response.
+class ReplyServer: public QTcpServer {
+ Q_OBJECT
+
+public:
+ explicit ReplyServer(QObject *parent = 0);
+
+ /// Page content on local host after successful oauth - in case you do not want to close the browser, but display something
+ Q_PROPERTY(QByteArray replyContent READ replyContent WRITE setReplyContent)
+ QByteArray replyContent();
+ void setReplyContent(const QByteArray &value);
+
+ /// Seconds to keep listening *after* first response for a callback with token content
+ Q_PROPERTY(int timeout READ timeout WRITE setTimeout)
+ int timeout();
+ void setTimeout(int timeout);
+
+ /// Maximum number of callback tries to accept, in case some don't have token content (favicons, etc.)
+ Q_PROPERTY(int callbackTries READ callbackTries WRITE setCallbackTries)
+ int callbackTries();
+ void setCallbackTries(int maxtries);
+
+ QString uniqueState();
+ void setUniqueState(const QString &state);
+
+signals:
+ void verificationReceived(QMap<QString, QString>);
+ void serverClosed(bool); // whether it has found parameters
+
+public slots:
+ void onIncomingConnection();
+ void onBytesReady();
+ QMap<QString, QString> parseQueryParams(QByteArray *data);
+ void closeServer(QTcpSocket *socket = 0, bool hasparameters = false);
+
+protected:
+ QByteArray replyContent_;
+ int timeout_;
+ int maxtries_;
+ int tries_;
+ QString uniqueState_;
+};
+
+}
diff --git a/libraries/katabasis/include/katabasis/RequestParameter.h b/libraries/katabasis/include/katabasis/RequestParameter.h
new file mode 100644
index 00000000..ca36934a
--- /dev/null
+++ b/libraries/katabasis/include/katabasis/RequestParameter.h
@@ -0,0 +1,15 @@
+#pragma once
+
+namespace Katabasis {
+
+/// Request parameter (name-value pair) participating in authentication.
+struct RequestParameter {
+ RequestParameter(const QByteArray &n, const QByteArray &v): name(n), value(v) {}
+ bool operator <(const RequestParameter &other) const {
+ return (name == other.name)? (value < other.value): (name < other.name);
+ }
+ QByteArray name;
+ QByteArray value;
+};
+
+}
diff --git a/libraries/katabasis/include/katabasis/Requestor.h b/libraries/katabasis/include/katabasis/Requestor.h
new file mode 100644
index 00000000..61437f76
--- /dev/null
+++ b/libraries/katabasis/include/katabasis/Requestor.h
@@ -0,0 +1,116 @@
+#pragma once
+#include <QObject>
+#include <QNetworkRequest>
+#include <QNetworkReply>
+#include <QNetworkAccessManager>
+#include <QUrl>
+#include <QByteArray>
+#include <QHttpMultiPart>
+
+#include "Reply.h"
+
+namespace Katabasis {
+
+class OAuth2;
+
+/// Makes authenticated requests.
+class Requestor: public QObject {
+ Q_OBJECT
+
+public:
+ explicit Requestor(QNetworkAccessManager *manager, OAuth2 *authenticator, QObject *parent = 0);
+ ~Requestor();
+
+
+ /// Some services require the access token to be sent as a Authentication HTTP header
+ /// and refuse requests with the access token in the query.
+ /// This function allows to use or ignore the access token in the query.
+ /// The default value of `true` means that the query will contain the access token.
+ /// By setting the value to false, the query will not contain the access token.
+ /// See:
+ /// https://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-16#section-4.3
+ /// https://tools.ietf.org/html/rfc6750#section-2.3
+
+ void setAddAccessTokenInQuery(bool value);
+
+ /// Some services require the access token to be sent as a Authentication HTTP header.
+ /// This is the case for Twitch and Mixer.
+ /// When the access token expires and is refreshed, O2Requestor::retry() needs to update the Authentication HTTP header.
+ /// In order to do so, O2Requestor needs to know the format of the Authentication HTTP header.
+ void setAccessTokenInAuthenticationHTTPHeaderFormat(const QString &value);
+
+public slots:
+ /// Make a GET request.
+ /// @return Request ID or -1 if there are too many requests in the queue.
+ int get(const QNetworkRequest &req, int timeout = 60*1000);
+
+ /// Make a POST request.
+ /// @return Request ID or -1 if there are too many requests in the queue.
+ int post(const QNetworkRequest &req, const QByteArray &data, int timeout = 60*1000);
+ int post(const QNetworkRequest &req, QHttpMultiPart* data, int timeout = 60*1000);
+
+ /// Make a PUT request.
+ /// @return Request ID or -1 if there are too many requests in the queue.
+ int put(const QNetworkRequest &req, const QByteArray &data, int timeout = 60*1000);
+ int put(const QNetworkRequest &req, QHttpMultiPart* data, int timeout = 60*1000);
+
+ /// Make a HEAD request.
+ /// @return Request ID or -1 if there are too many requests in the queue.
+ int head(const QNetworkRequest &req, int timeout = 60*1000);
+
+ /// Make a custom request.
+ /// @return Request ID or -1 if there are too many requests in the queue.
+ int customRequest(const QNetworkRequest &req, const QByteArray &verb, const QByteArray &data, int timeout = 60*1000);
+
+signals:
+
+ /// Emitted when a request has been completed or failed.
+ void finished(int id, QNetworkReply::NetworkError error, QByteArray data, QList<QNetworkReply::RawHeaderPair> headers);
+
+ /// Emitted when an upload has progressed.
+ void uploadProgress(int id, qint64 bytesSent, qint64 bytesTotal);
+
+protected slots:
+ /// Handle refresh completion.
+ void onRefreshFinished(QNetworkReply::NetworkError error);
+
+ /// Handle request finished.
+ void onRequestFinished();
+
+ /// Handle request error.
+ void onRequestError(QNetworkReply::NetworkError error);
+
+ /// Re-try request (after successful token refresh).
+ void retry();
+
+ /// Finish the request, emit finished() signal.
+ void finish();
+
+ /// Handle upload progress.
+ void onUploadProgress(qint64 uploaded, qint64 total);
+
+protected:
+ int setup(const QNetworkRequest &request, QNetworkAccessManager::Operation operation, const QByteArray &verb = QByteArray());
+
+ enum Status {
+ Idle, Requesting, ReRequesting
+ };
+
+ QNetworkAccessManager *manager_;
+ OAuth2 *authenticator_;
+ QNetworkRequest request_;
+ QByteArray data_;
+ QHttpMultiPart* multipartData_;
+ QNetworkReply *reply_;
+ Status status_;
+ int id_;
+ QNetworkAccessManager::Operation operation_;
+ QUrl url_;
+ ReplyList timedReplies_;
+ QNetworkReply::NetworkError error_;
+ bool addAccessTokenInQuery_;
+ QString accessTokenInAuthenticationHTTPHeaderFormat_;
+ bool rawData_;
+};
+
+}
diff --git a/libraries/katabasis/src/JsonResponse.cpp b/libraries/katabasis/src/JsonResponse.cpp
new file mode 100644
index 00000000..63384d12
--- /dev/null
+++ b/libraries/katabasis/src/JsonResponse.cpp
@@ -0,0 +1,26 @@
+#include "JsonResponse.h"
+
+#include <QByteArray>
+#include <QDebug>
+#include <QJsonDocument>
+#include <QJsonObject>
+
+namespace Katabasis {
+
+QVariantMap parseJsonResponse(const QByteArray &data) {
+ QJsonParseError err;
+ QJsonDocument doc = QJsonDocument::fromJson(data, &err);
+ if (err.error != QJsonParseError::NoError) {
+ qWarning() << "parseTokenResponse: Failed to parse token response due to err:" << err.errorString();
+ return QVariantMap();
+ }
+
+ if (!doc.isObject()) {
+ qWarning() << "parseTokenResponse: Token response is not an object";
+ return QVariantMap();
+ }
+
+ return doc.object().toVariantMap();
+}
+
+}
diff --git a/libraries/katabasis/src/JsonResponse.h b/libraries/katabasis/src/JsonResponse.h
new file mode 100644
index 00000000..e7fe7e30
--- /dev/null
+++ b/libraries/katabasis/src/JsonResponse.h
@@ -0,0 +1,12 @@
+#pragma once
+
+#include <QVariantMap>
+
+class QByteArray;
+
+namespace Katabasis {
+
+ /// Parse JSON data into a QVariantMap
+QVariantMap parseJsonResponse(const QByteArray &data);
+
+}
diff --git a/libraries/katabasis/src/OAuth2.cpp b/libraries/katabasis/src/OAuth2.cpp
new file mode 100644
index 00000000..6cc03a0d
--- /dev/null
+++ b/libraries/katabasis/src/OAuth2.cpp
@@ -0,0 +1,668 @@
+#include <QList>
+#include <QPair>
+#include <QDebug>
+#include <QTcpServer>
+#include <QMap>
+#include <QNetworkRequest>
+#include <QNetworkReply>
+#include <QNetworkAccessManager>
+#include <QDateTime>
+#include <QCryptographicHash>
+#include <QTimer>
+#include <QVariantMap>
+#include <QUuid>
+#include <QDataStream>
+
+#include <QUrlQuery>
+
+#include "katabasis/OAuth2.h"
+#include "katabasis/PollServer.h"
+#include "katabasis/ReplyServer.h"
+#include "katabasis/Globals.h"
+
+#include "JsonResponse.h"
+
+namespace {
+// ref: https://tools.ietf.org/html/rfc8628#section-3.2
+// Exception: Google sign-in uses "verification_url" instead of "*_uri" - we'll accept both.
+bool hasMandatoryDeviceAuthParams(const QVariantMap& params)
+{
+ if (!params.contains(Katabasis::OAUTH2_DEVICE_CODE))
+ return false;
+
+ if (!params.contains(Katabasis::OAUTH2_USER_CODE))
+ return false;
+
+ if (!(params.contains(Katabasis::OAUTH2_VERIFICATION_URI) || params.contains(Katabasis::OAUTH2_VERIFICATION_URL)))
+ return false;
+
+ if (!params.contains(Katabasis::OAUTH2_EXPIRES_IN))
+ return false;
+
+ return true;
+}
+
+QByteArray createQueryParameters(const QList<Katabasis::RequestParameter> &parameters) {
+ QByteArray ret;
+ bool first = true;
+ for( auto & h: parameters) {
+ if (first) {
+ first = false;
+ } else {
+ ret.append("&");
+ }
+ ret.append(QUrl::toPercentEncoding(h.name) + "=" + QUrl::toPercentEncoding(h.value));
+ }
+ return ret;
+}
+}
+
+namespace Katabasis {
+
+OAuth2::OAuth2(Options & opts, Token & token, QObject *parent, QNetworkAccessManager *manager) : QObject(parent), token_(token) {
+ manager_ = manager ? manager : new QNetworkAccessManager(this);
+ grantFlow_ = GrantFlowAuthorizationCode;
+ qRegisterMetaType<QNetworkReply::NetworkError>("QNetworkReply::NetworkError");
+ options_ = opts;
+}
+
+bool OAuth2::linked() {
+ return token_.validity != Validity::None;
+}
+void OAuth2::setLinked(bool v) {
+ qDebug() << "OAuth2::setLinked:" << (v? "true": "false");
+ token_.validity = v ? Validity::Certain : Validity::None;
+}
+
+QString OAuth2::token() {
+ return token_.token;
+}
+void OAuth2::setToken(const QString &v) {
+ token_.token = v;
+}
+
+QByteArray OAuth2::replyContent() const {
+ return replyContent_;
+}
+
+void OAuth2::setReplyContent(const QByteArray &value) {
+ replyContent_ = value;
+ if (replyServer_) {
+ replyServer_->setReplyContent(replyContent_);
+ }
+}
+
+QVariantMap OAuth2::extraTokens() {
+ return token_.extra;
+}
+
+void OAuth2::setExtraTokens(QVariantMap extraTokens) {
+ token_.extra = extraTokens;
+}
+
+void OAuth2::setReplyServer(ReplyServer * server)
+{
+ delete replyServer_;
+
+ replyServer_ = server;
+ replyServer_->setReplyContent(replyContent_);
+}
+
+ReplyServer * OAuth2::replyServer() const
+{
+ return replyServer_;
+}
+
+void OAuth2::setPollServer(PollServer *server)
+{
+ if (pollServer_)
+ pollServer_->deleteLater();
+
+ pollServer_ = server;
+}
+
+PollServer *OAuth2::pollServer() const
+{
+ return pollServer_;
+}
+
+OAuth2::GrantFlow OAuth2::grantFlow() {
+ return grantFlow_;
+}
+
+void OAuth2::setGrantFlow(OAuth2::GrantFlow value) {
+ grantFlow_ = value;
+}
+
+QString OAuth2::username() {
+ return username_;
+}
+
+void OAuth2::setUsername(const QString &value) {
+ username_ = value;
+}
+
+QString OAuth2::password() {
+ return password_;
+}
+
+void OAuth2::setPassword(const QString &value) {
+ password_ = value;
+}
+
+QVariantMap OAuth2::extraRequestParams()
+{
+ return extraReqParams_;
+}
+
+void OAuth2::setExtraRequestParams(const QVariantMap &value)
+{
+ extraReqParams_ = value;
+}
+
+QString OAuth2::grantType()
+{
+ if (!grantType_.isEmpty())
+ return grantType_;
+
+ switch (grantFlow_) {
+ case GrantFlowAuthorizationCode:
+ return OAUTH2_GRANT_TYPE_CODE;
+ case GrantFlowImplicit:
+ return OAUTH2_GRANT_TYPE_TOKEN;
+ case GrantFlowResourceOwnerPasswordCredentials:
+ return OAUTH2_GRANT_TYPE_PASSWORD;
+ case GrantFlowDevice:
+ return OAUTH2_GRANT_TYPE_DEVICE;
+ }
+
+ return QString();
+}
+
+void OAuth2::setGrantType(const QString &value)
+{
+ grantType_ = value;
+}
+
+void OAuth2::updateActivity(Activity activity)
+{
+ if(activity_ != activity) {
+ activity_ = activity;
+ emit activityChanged(activity_);
+ }
+}
+
+void OAuth2::link() {
+ qDebug() << "OAuth2::link";
+
+ // Create the reply server if it doesn't exist
+ if(replyServer() == NULL) {
+ ReplyServer * replyServer = new ReplyServer(this);
+ connect(replyServer, &ReplyServer::verificationReceived, this, &OAuth2::onVerificationReceived);
+ connect(replyServer, &ReplyServer::serverClosed, this, &OAuth2::serverHasClosed);
+ setReplyServer(replyServer);
+ }
+
+ if (linked()) {
+ qDebug() << "OAuth2::link: Linked already";
+ emit linkingSucceeded();
+ return;
+ }
+
+ setLinked(false);
+ setToken("");
+ setExtraTokens(QVariantMap());
+ setRefreshToken(QString());
+ setExpires(QDateTime());
+
+ if (grantFlow_ == GrantFlowAuthorizationCode || grantFlow_ == GrantFlowImplicit) {
+
+ QString uniqueState = QUuid::createUuid().toString().remove(QRegExp("([^a-zA-Z0-9]|[-])"));
+
+ // FIXME: this should be part of a 'redirection handler' that would get injected into O2
+ {
+ quint16 foundPort = 0;
+ // Start listening to authentication replies
+ if (!replyServer()->isListening()) {
+ auto ports = options_.listenerPorts;
+ for(auto & port: ports) {
+ if (replyServer()->listen(QHostAddress::Any, port)) {
+ foundPort = replyServer()->serverPort();
+ qDebug() << "OAuth2::link: Reply server listening on port " << foundPort;
+ break;
+ }
+ }
+ if(foundPort == 0) {
+ qWarning() << "OAuth2::link: Reply server failed to start listening on any port out of " << ports;
+ emit linkingFailed();
+ return;
+ }
+ }
+
+ // Save redirect URI, as we have to reuse it when requesting the access token
+ redirectUri_ = options_.redirectionUrl.arg(foundPort);
+ replyServer()->setUniqueState(uniqueState);
+ }
+
+ // Assemble intial authentication URL
+ QUrl url(options_.authorizationUrl);
+ QUrlQuery query(url);
+ QList<QPair<QString, QString> > parameters;
+ query.addQueryItem(OAUTH2_RESPONSE_TYPE, (grantFlow_ == GrantFlowAuthorizationCode)? OAUTH2_GRANT_TYPE_CODE: OAUTH2_GRANT_TYPE_TOKEN);
+ query.addQueryItem(OAUTH2_CLIENT_ID, options_.clientIdentifier);
+ query.addQueryItem(OAUTH2_REDIRECT_URI, redirectUri_);
+ query.addQueryItem(OAUTH2_SCOPE, options_.scope.replace( " ", "+" ));
+ query.addQueryItem(OAUTH2_STATE, uniqueState);
+ if (!apiKey_.isEmpty()) {
+ query.addQueryItem(OAUTH2_API_KEY, apiKey_);
+ }
+ for(auto iter = extraReqParams_.begin(); iter != extraReqParams_.end(); iter++) {
+ query.addQueryItem(iter.key(), iter.value().toString());
+ }
+ url.setQuery(query);
+
+ // Show authentication URL with a web browser
+ qDebug() << "OAuth2::link: Emit openBrowser" << url.toString();
+ emit openBrowser(url);
+ updateActivity(Activity::LoggingIn);
+ } else if (grantFlow_ == GrantFlowResourceOwnerPasswordCredentials) {
+ QList<RequestParameter> parameters;
+ parameters.append(RequestParameter(OAUTH2_CLIENT_ID, options_.clientIdentifier.toUtf8()));
+ if ( !options_.clientSecret.isEmpty() ) {
+ parameters.append(RequestParameter(OAUTH2_CLIENT_SECRET, options_.clientSecret.toUtf8()));
+ }
+ parameters.append(RequestParameter(OAUTH2_USERNAME, username_.toUtf8()));
+ parameters.append(RequestParameter(OAUTH2_PASSWORD, password_.toUtf8()));
+ parameters.append(RequestParameter(OAUTH2_GRANT_TYPE, OAUTH2_GRANT_TYPE_PASSWORD));
+ parameters.append(RequestParameter(OAUTH2_SCOPE, options_.scope.toUtf8()));
+ if ( !apiKey_.isEmpty() )
+ parameters.append(RequestParameter(OAUTH2_API_KEY, apiKey_.toUtf8()));
+ foreach (QString key, extraRequestParams().keys()) {
+ parameters.append(RequestParameter(key.toUtf8(), extraRequestParams().value(key).toByteArray()));
+ }
+ QByteArray payload = createQueryParameters(parameters);
+
+ qDebug() << "OAuth2::link: Sending token request for resource owner flow";
+ QUrl url(options_.accessTokenUrl);
+ QNetworkRequest tokenRequest(url);
+ tokenRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
+ QNetworkReply *tokenReply = manager_->post(tokenRequest, payload);
+
+ connect(tokenReply, SIGNAL(finished()), this, SLOT(onTokenReplyFinished()), Qt::QueuedConnection);
+ connect(tokenReply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onTokenReplyError(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ updateActivity(Activity::LoggingIn);
+ }
+ else if (grantFlow_ == GrantFlowDevice) {
+ QList<RequestParameter> parameters;
+ parameters.append(RequestParameter(OAUTH2_CLIENT_ID, options_.clientIdentifier.toUtf8()));
+ parameters.append(RequestParameter(OAUTH2_SCOPE, options_.scope.toUtf8()));
+ QByteArray payload = createQueryParameters(parameters);
+
+ QUrl url(options_.authorizationUrl);
+ QNetworkRequest deviceRequest(url);
+ deviceRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
+ QNetworkReply *tokenReply = manager_->post(deviceRequest, payload);
+
+ connect(tokenReply, SIGNAL(finished()), this, SLOT(onDeviceAuthReplyFinished()), Qt::QueuedConnection);
+ connect(tokenReply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onTokenReplyError(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ updateActivity(Activity::LoggingIn);
+ }
+}
+
+void OAuth2::unlink() {
+ qDebug() << "OAuth2::unlink";
+ updateActivity(Activity::LoggingOut);
+ // FIXME: implement logout flows... if they exist
+ token_ = Token();
+ updateActivity(Activity::Idle);
+}
+
+void OAuth2::onVerificationReceived(const QMap<QString, QString> response) {
+ qDebug() << "OAuth2::onVerificationReceived: Emitting closeBrowser()";
+ emit closeBrowser();
+
+ if (response.contains("error")) {
+ qWarning() << "OAuth2::onVerificationReceived: Verification failed:" << response;
+ emit linkingFailed();
+ updateActivity(Activity::Idle);
+ return;
+ }
+
+ if (grantFlow_ == GrantFlowAuthorizationCode) {
+ // NOTE: access code is temporary and should never be saved anywhere!
+ auto access_code = response.value(QString(OAUTH2_GRANT_TYPE_CODE));
+
+ // Exchange access code for access/refresh tokens
+ QString query;
+ if(!apiKey_.isEmpty())
+ query = QString("?" + QString(OAUTH2_API_KEY) + "=" + apiKey_);
+ QNetworkRequest tokenRequest(QUrl(options_.accessTokenUrl.toString() + query));
+ tokenRequest.setHeader(QNetworkRequest::ContentTypeHeader, MIME_TYPE_XFORM);
+ tokenRequest.setRawHeader("Accept", MIME_TYPE_JSON);
+ QMap<QString, QString> parameters;
+ parameters.insert(OAUTH2_GRANT_TYPE_CODE, access_code);
+ parameters.insert(OAUTH2_CLIENT_ID, options_.clientIdentifier);
+ if ( !options_.clientSecret.isEmpty() ) {
+ parameters.insert(OAUTH2_CLIENT_SECRET, options_.clientSecret);
+ }
+ parameters.insert(OAUTH2_REDIRECT_URI, redirectUri_);
+ parameters.insert(OAUTH2_GRANT_TYPE, AUTHORIZATION_CODE);
+ QByteArray data = buildRequestBody(parameters);
+
+ qDebug() << QString("OAuth2::onVerificationReceived: Exchange access code data:\n%1").arg(QString(data));
+
+ QNetworkReply *tokenReply = manager_->post(tokenRequest, data);
+ timedReplies_.add(tokenReply);
+ connect(tokenReply, SIGNAL(finished()), this, SLOT(onTokenReplyFinished()), Qt::QueuedConnection);
+ connect(tokenReply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onTokenReplyError(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ } else if (grantFlow_ == GrantFlowImplicit || grantFlow_ == GrantFlowDevice) {
+ // Check for mandatory tokens
+ if (response.contains(OAUTH2_ACCESS_TOKEN)) {
+ qDebug() << "OAuth2::onVerificationReceived: Access token returned for implicit or device flow";
+ setToken(response.value(OAUTH2_ACCESS_TOKEN));
+ if (response.contains(OAUTH2_EXPIRES_IN)) {
+ bool ok = false;
+ int expiresIn = response.value(OAUTH2_EXPIRES_IN).toInt(&ok);
+ if (ok) {
+ qDebug() << "OAuth2::onVerificationReceived: Token expires in" << expiresIn << "seconds";
+ setExpires(QDateTime::currentDateTimeUtc().addSecs(expiresIn));
+ }
+ }
+ if (response.contains(OAUTH2_REFRESH_TOKEN)) {
+ setRefreshToken(response.value(OAUTH2_REFRESH_TOKEN));
+ }
+ setLinked(true);
+ emit linkingSucceeded();
+ } else {
+ qWarning() << "OAuth2::onVerificationReceived: Access token missing from response for implicit or device flow";
+ emit linkingFailed();
+ }
+ updateActivity(Activity::Idle);
+ } else {
+ setToken(response.value(OAUTH2_ACCESS_TOKEN));
+ setRefreshToken(response.value(OAUTH2_REFRESH_TOKEN));
+ updateActivity(Activity::Idle);
+ }
+}
+
+void OAuth2::onTokenReplyFinished() {
+ qDebug() << "OAuth2::onTokenReplyFinished";
+ QNetworkReply *tokenReply = qobject_cast<QNetworkReply *>(sender());
+ if (!tokenReply)
+ {
+ qDebug() << "OAuth2::onTokenReplyFinished: reply is null";
+ return;
+ }
+ if (tokenReply->error() == QNetworkReply::NoError) {
+ QByteArray replyData = tokenReply->readAll();
+
+ // Dump replyData
+ // SENSITIVE DATA in RelWithDebInfo or Debug builds
+ //qDebug() << "OAuth2::onTokenReplyFinished: replyData\n";
+ //qDebug() << QString( replyData );
+
+ QVariantMap tokens = parseJsonResponse(replyData);
+
+ // Dump tokens
+ qDebug() << "OAuth2::onTokenReplyFinished: Tokens returned:\n";
+ foreach (QString key, tokens.keys()) {
+ // SENSITIVE DATA in RelWithDebInfo or Debug builds, so it is truncated first
+ qDebug() << key << ": "<< tokens.value( key ).toString();
+ }
+
+ // Check for mandatory tokens
+ if (tokens.contains(OAUTH2_ACCESS_TOKEN)) {
+ qDebug() << "OAuth2::onTokenReplyFinished: Access token returned";
+ setToken(tokens.take(OAUTH2_ACCESS_TOKEN).toString());
+ bool ok = false;
+ int expiresIn = tokens.take(OAUTH2_EXPIRES_IN).toInt(&ok);
+ if (ok) {
+ qDebug() << "OAuth2::onTokenReplyFinished: Token expires in" << expiresIn << "seconds";
+ setExpires(QDateTime::currentDateTimeUtc().addSecs(expiresIn));
+ }
+ setRefreshToken(tokens.take(OAUTH2_REFRESH_TOKEN).toString());
+ setExtraTokens(tokens);
+ timedReplies_.remove(tokenReply);
+ setLinked(true);
+ emit linkingSucceeded();
+ } else {
+ qWarning() << "OAuth2::onTokenReplyFinished: Access token missing from response";
+ emit linkingFailed();
+ }
+ }
+ tokenReply->deleteLater();
+ updateActivity(Activity::Idle);
+}
+
+void OAuth2::onTokenReplyError(QNetworkReply::NetworkError error) {
+ QNetworkReply *tokenReply = qobject_cast<QNetworkReply *>(sender());
+ if (!tokenReply)
+ {
+ qDebug() << "OAuth2::onTokenReplyError: reply is null";
+ } else {
+ qWarning() << "OAuth2::onTokenReplyError: " << error << ": " << tokenReply->errorString();
+ qDebug() << "OAuth2::onTokenReplyError: " << tokenReply->readAll();
+ timedReplies_.remove(tokenReply);
+ }
+
+ setToken(QString());
+ setRefreshToken(QString());
+ emit linkingFailed();
+}
+
+QByteArray OAuth2::buildRequestBody(const QMap<QString, QString> &parameters) {
+ QByteArray body;
+ bool first = true;
+ foreach (QString key, parameters.keys()) {
+ if (first) {
+ first = false;
+ } else {
+ body.append("&");
+ }
+ QString value = parameters.value(key);
+ body.append(QUrl::toPercentEncoding(key) + QString("=").toUtf8() + QUrl::toPercentEncoding(value));
+ }
+ return body;
+}
+
+QDateTime OAuth2::expires() {
+ return token_.notAfter;
+}
+void OAuth2::setExpires(QDateTime v) {
+ token_.notAfter = v;
+}
+
+void OAuth2::startPollServer(const QVariantMap &params)
+{
+ bool ok = false;
+ int expiresIn = params[OAUTH2_EXPIRES_IN].toInt(&ok);
+ if (!ok) {
+ qWarning() << "OAuth2::startPollServer: No expired_in parameter";
+ emit linkingFailed();
+ return;
+ }
+
+ qDebug() << "OAuth2::startPollServer: device_ and user_code expires in" << expiresIn << "seconds";
+
+ QUrl url(options_.accessTokenUrl);
+ QNetworkRequest authRequest(url);
+ authRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
+
+ const QString deviceCode = params[OAUTH2_DEVICE_CODE].toString();
+ const QString grantType = grantType_.isEmpty() ? OAUTH2_GRANT_TYPE_DEVICE : grantType_;
+
+ QList<RequestParameter> parameters;
+ parameters.append(RequestParameter(OAUTH2_CLIENT_ID, options_.clientIdentifier.toUtf8()));
+ if ( !options_.clientSecret.isEmpty() ) {
+ parameters.append(RequestParameter(OAUTH2_CLIENT_SECRET, options_.clientSecret.toUtf8()));
+ }
+ parameters.append(RequestParameter(OAUTH2_CODE, deviceCode.toUtf8()));
+ parameters.append(RequestParameter(OAUTH2_GRANT_TYPE, grantType.toUtf8()));
+ QByteArray payload = createQueryParameters(parameters);
+
+ PollServer * pollServer = new PollServer(manager_, authRequest, payload, expiresIn, this);
+ if (params.contains(OAUTH2_INTERVAL)) {
+ int interval = params[OAUTH2_INTERVAL].toInt(&ok);
+ if (ok)
+ pollServer->setInterval(interval);
+ }
+ connect(pollServer, SIGNAL(verificationReceived(QMap<QString,QString>)), this, SLOT(onVerificationReceived(QMap<QString,QString>)));
+ connect(pollServer, SIGNAL(serverClosed(bool)), this, SLOT(serverHasClosed(bool)));
+ setPollServer(pollServer);
+ pollServer->startPolling();
+}
+
+QString OAuth2::refreshToken() {
+ return token_.refresh_token;
+}
+void OAuth2::setRefreshToken(const QString &v) {
+ qDebug() << "OAuth2::setRefreshToken" << v << "...";
+ token_.refresh_token = v;
+}
+
+bool OAuth2::refresh() {
+ qDebug() << "OAuth2::refresh: Token: ..." << refreshToken().right(7);
+
+ if (refreshToken().isEmpty()) {
+ qWarning() << "OAuth2::refresh: No refresh token";
+ onRefreshError(QNetworkReply::AuthenticationRequiredError);
+ return false;
+ }
+ if (options_.accessTokenUrl.isEmpty()) {
+ qWarning() << "OAuth2::refresh: Refresh token URL not set";
+ onRefreshError(QNetworkReply::AuthenticationRequiredError);
+ return false;
+ }
+
+ updateActivity(Activity::Refreshing);
+
+ QNetworkRequest refreshRequest(options_.accessTokenUrl);
+ refreshRequest.setHeader(QNetworkRequest::ContentTypeHeader, MIME_TYPE_XFORM);
+ QMap<QString, QString> parameters;
+ parameters.insert(OAUTH2_CLIENT_ID, options_.clientIdentifier);
+ if ( !options_.clientSecret.isEmpty() ) {
+ parameters.insert(OAUTH2_CLIENT_SECRET, options_.clientSecret);
+ }
+ parameters.insert(OAUTH2_REFRESH_TOKEN, refreshToken());
+ parameters.insert(OAUTH2_GRANT_TYPE, OAUTH2_REFRESH_TOKEN);
+
+ QByteArray data = buildRequestBody(parameters);
+ QNetworkReply *refreshReply = manager_->post(refreshRequest, data);
+ timedReplies_.add(refreshReply);
+ connect(refreshReply, SIGNAL(finished()), this, SLOT(onRefreshFinished()), Qt::QueuedConnection);
+ connect(refreshReply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRefreshError(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ return true;
+}
+
+void OAuth2::onRefreshFinished() {
+ QNetworkReply *refreshReply = qobject_cast<QNetworkReply *>(sender());
+
+ if (refreshReply->error() == QNetworkReply::NoError) {
+ QByteArray reply = refreshReply->readAll();
+ QVariantMap tokens = parseJsonResponse(reply);
+ setToken(tokens.value(OAUTH2_ACCESS_TOKEN).toString());
+ setExpires(QDateTime::currentDateTimeUtc().addSecs(tokens.value(OAUTH2_EXPIRES_IN).toInt()));
+ QString refreshToken = tokens.value(OAUTH2_REFRESH_TOKEN).toString();
+ if(!refreshToken.isEmpty()) {
+ setRefreshToken(refreshToken);
+ }
+ else {
+ qDebug() << "No new refresh token. Keep the old one.";
+ }
+ timedReplies_.remove(refreshReply);
+ setLinked(true);
+ emit linkingSucceeded();
+ emit refreshFinished(QNetworkReply::NoError);
+ qDebug() << " New token expires in" << expires() << "seconds";
+ } else {
+ qDebug() << "OAuth2::onRefreshFinished: Error" << (int)refreshReply->error() << refreshReply->errorString();
+ }
+ refreshReply->deleteLater();
+ updateActivity(Activity::Idle);
+}
+
+void OAuth2::onRefreshError(QNetworkReply::NetworkError error) {
+ QNetworkReply *refreshReply = qobject_cast<QNetworkReply *>(sender());
+ qWarning() << "OAuth2::onRefreshError: " << error;
+ unlink();
+ timedReplies_.remove(refreshReply);
+ emit refreshFinished(error);
+}
+
+void OAuth2::onDeviceAuthReplyFinished()
+{
+ qDebug() << "OAuth2::onDeviceAuthReplyFinished";
+ QNetworkReply *tokenReply = qobject_cast<QNetworkReply *>(sender());
+ if (!tokenReply)
+ {
+ qDebug() << "OAuth2::onDeviceAuthReplyFinished: reply is null";
+ return;
+ }
+ if (tokenReply->error() == QNetworkReply::NoError) {
+ QByteArray replyData = tokenReply->readAll();
+
+ // Dump replyData
+ // SENSITIVE DATA in RelWithDebInfo or Debug builds
+ //qDebug() << "OAuth2::onDeviceAuthReplyFinished: replyData\n";
+ //qDebug() << QString( replyData );
+
+ QVariantMap params = parseJsonResponse(replyData);
+
+ // Dump tokens
+ qDebug() << "OAuth2::onDeviceAuthReplyFinished: Tokens returned:\n";
+ foreach (QString key, params.keys()) {
+ // SENSITIVE DATA in RelWithDebInfo or Debug builds, so it is truncated first
+ qDebug() << key << ": "<< params.value( key ).toString();
+ }
+
+ // Check for mandatory parameters
+ if (hasMandatoryDeviceAuthParams(params)) {
+ qDebug() << "OAuth2::onDeviceAuthReplyFinished: Device auth request response";
+
+ const QString userCode = params.take(OAUTH2_USER_CODE).toString();
+ QUrl uri = params.take(OAUTH2_VERIFICATION_URI).toUrl();
+ if (uri.isEmpty())
+ uri = params.take(OAUTH2_VERIFICATION_URL).toUrl();
+
+ if (params.contains(OAUTH2_VERIFICATION_URI_COMPLETE))
+ emit openBrowser(params.take(OAUTH2_VERIFICATION_URI_COMPLETE).toUrl());
+
+ emit showVerificationUriAndCode(uri, userCode);
+
+ startPollServer(params);
+ } else {
+ qWarning() << "OAuth2::onDeviceAuthReplyFinished: Mandatory parameters missing from response";
+ emit linkingFailed();
+ updateActivity(Activity::Idle);
+ }
+ }
+ tokenReply->deleteLater();
+}
+
+void OAuth2::serverHasClosed(bool paramsfound)
+{
+ if ( !paramsfound ) {
+ // server has probably timed out after receiving first response
+ emit linkingFailed();
+ }
+ // poll server is not re-used for later auth requests
+ setPollServer(NULL);
+}
+
+QString OAuth2::apiKey() {
+ return apiKey_;
+}
+
+void OAuth2::setApiKey(const QString &value) {
+ apiKey_ = value;
+}
+
+bool OAuth2::ignoreSslErrors() {
+ return timedReplies_.ignoreSslErrors();
+}
+
+void OAuth2::setIgnoreSslErrors(bool ignoreSslErrors) {
+ timedReplies_.setIgnoreSslErrors(ignoreSslErrors);
+}
+
+}
diff --git a/libraries/katabasis/src/PollServer.cpp b/libraries/katabasis/src/PollServer.cpp
new file mode 100644
index 00000000..1083c599
--- /dev/null
+++ b/libraries/katabasis/src/PollServer.cpp
@@ -0,0 +1,123 @@
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+
+#include "katabasis/PollServer.h"
+#include "JsonResponse.h"
+
+namespace {
+QMap<QString, QString> toVerificationParams(const QVariantMap &map)
+{
+ QMap<QString, QString> params;
+ for (QVariantMap::const_iterator i = map.constBegin();
+ i != map.constEnd(); ++i)
+ {
+ params[i.key()] = i.value().toString();
+ }
+ return params;
+}
+}
+
+namespace Katabasis {
+
+PollServer::PollServer(QNetworkAccessManager *manager, const QNetworkRequest &request, const QByteArray &payload, int expiresIn, QObject *parent)
+ : QObject(parent)
+ , manager_(manager)
+ , request_(request)
+ , payload_(payload)
+ , expiresIn_(expiresIn)
+{
+ expirationTimer.setTimerType(Qt::VeryCoarseTimer);
+ expirationTimer.setInterval(expiresIn * 1000);
+ expirationTimer.setSingleShot(true);
+ connect(&expirationTimer, SIGNAL(timeout()), this, SLOT(onExpiration()));
+ expirationTimer.start();
+
+ pollTimer.setTimerType(Qt::VeryCoarseTimer);
+ pollTimer.setInterval(5 * 1000);
+ pollTimer.setSingleShot(true);
+ connect(&pollTimer, SIGNAL(timeout()), this, SLOT(onPollTimeout()));
+}
+
+int PollServer::interval() const
+{
+ return pollTimer.interval() / 1000;
+}
+
+void PollServer::setInterval(int interval)
+{
+ pollTimer.setInterval(interval * 1000);
+}
+
+void PollServer::startPolling()
+{
+ if (expirationTimer.isActive()) {
+ pollTimer.start();
+ }
+}
+
+void PollServer::onPollTimeout()
+{
+ qDebug() << "PollServer::onPollTimeout: retrying";
+ QNetworkReply * reply = manager_->post(request_, payload_);
+ connect(reply, SIGNAL(finished()), this, SLOT(onReplyFinished()));
+}
+
+void PollServer::onExpiration()
+{
+ pollTimer.stop();
+ emit serverClosed(false);
+}
+
+void PollServer::onReplyFinished()
+{
+ QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
+
+ if (!reply) {
+ qDebug() << "PollServer::onReplyFinished: reply is null";
+ return;
+ }
+
+ QByteArray replyData = reply->readAll();
+ QMap<QString, QString> params = toVerificationParams(parseJsonResponse(replyData));
+
+ // Dump replyData
+ // SENSITIVE DATA in RelWithDebInfo or Debug builds
+ // qDebug() << "PollServer::onReplyFinished: replyData\n";
+ // qDebug() << QString( replyData );
+
+ if (reply->error() == QNetworkReply::TimeoutError) {
+ // rfc8628#section-3.2
+ // "On encountering a connection timeout, clients MUST unilaterally
+ // reduce their polling frequency before retrying. The use of an
+ // exponential backoff algorithm to achieve this, such as doubling the
+ // polling interval on each such connection timeout, is RECOMMENDED."
+ setInterval(interval() * 2);
+ pollTimer.start();
+ }
+ else {
+ QString error = params.value("error");
+ if (error == "slow_down") {
+ // rfc8628#section-3.2
+ // "A variant of 'authorization_pending', the authorization request is
+ // still pending and polling should continue, but the interval MUST
+ // be increased by 5 seconds for this and all subsequent requests."
+ setInterval(interval() + 5);
+ pollTimer.start();
+ }
+ else if (error == "authorization_pending") {
+ // keep trying - rfc8628#section-3.2
+ // "The authorization request is still pending as the end user hasn't
+ // yet completed the user-interaction steps (Section 3.3)."
+ pollTimer.start();
+ }
+ else {
+ expirationTimer.stop();
+ emit serverClosed(true);
+ // let O2 handle the other cases
+ emit verificationReceived(params);
+ }
+ }
+ reply->deleteLater();
+}
+
+}
diff --git a/libraries/katabasis/src/Reply.cpp b/libraries/katabasis/src/Reply.cpp
new file mode 100644
index 00000000..775b9202
--- /dev/null
+++ b/libraries/katabasis/src/Reply.cpp
@@ -0,0 +1,62 @@
+#include <QTimer>
+#include <QNetworkReply>
+
+#include "katabasis/Reply.h"
+
+namespace Katabasis {
+
+Reply::Reply(QNetworkReply *r, int timeOut, QObject *parent): QTimer(parent), reply(r) {
+ setSingleShot(true);
+ connect(this, SIGNAL(error(QNetworkReply::NetworkError)), reply, SIGNAL(error(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ connect(this, SIGNAL(timeout()), this, SLOT(onTimeOut()), Qt::QueuedConnection);
+ start(timeOut);
+}
+
+void Reply::onTimeOut() {
+ emit error(QNetworkReply::TimeoutError);
+}
+
+ReplyList::~ReplyList() {
+ foreach (Reply *timedReply, replies_) {
+ delete timedReply;
+ }
+}
+
+void ReplyList::add(QNetworkReply *reply) {
+ if (reply && ignoreSslErrors())
+ reply->ignoreSslErrors();
+ add(new Reply(reply));
+}
+
+void ReplyList::add(Reply *reply) {
+ replies_.append(reply);
+}
+
+void ReplyList::remove(QNetworkReply *reply) {
+ Reply *o2Reply = find(reply);
+ if (o2Reply) {
+ o2Reply->stop();
+ (void)replies_.removeOne(o2Reply);
+ }
+}
+
+Reply *ReplyList::find(QNetworkReply *reply) {
+ foreach (Reply *timedReply, replies_) {
+ if (timedReply->reply == reply) {
+ return timedReply;
+ }
+ }
+ return 0;
+}
+
+bool ReplyList::ignoreSslErrors()
+{
+ return ignoreSslErrors_;
+}
+
+void ReplyList::setIgnoreSslErrors(bool ignoreSslErrors)
+{
+ ignoreSslErrors_ = ignoreSslErrors;
+}
+
+}
diff --git a/libraries/katabasis/src/ReplyServer.cpp b/libraries/katabasis/src/ReplyServer.cpp
new file mode 100755
index 00000000..4598b18a
--- /dev/null
+++ b/libraries/katabasis/src/ReplyServer.cpp
@@ -0,0 +1,182 @@
+#include <QTcpServer>
+#include <QTcpSocket>
+#include <QByteArray>
+#include <QString>
+#include <QMap>
+#include <QPair>
+#include <QTimer>
+#include <QStringList>
+#include <QUrl>
+#include <QDebug>
+#include <QUrlQuery>
+
+#include "katabasis/Globals.h"
+#include "katabasis/ReplyServer.h"
+
+namespace Katabasis {
+
+ReplyServer::ReplyServer(QObject *parent): QTcpServer(parent),
+ timeout_(15), maxtries_(3), tries_(0) {
+ qDebug() << "O2ReplyServer: Starting";
+ connect(this, SIGNAL(newConnection()), this, SLOT(onIncomingConnection()));
+ replyContent_ = "<HTML></HTML>";
+}
+
+void ReplyServer::onIncomingConnection() {
+ qDebug() << "O2ReplyServer::onIncomingConnection: Receiving...";
+ QTcpSocket *socket = nextPendingConnection();
+ connect(socket, SIGNAL(readyRead()), this, SLOT(onBytesReady()), Qt::UniqueConnection);
+ connect(socket, SIGNAL(disconnected()), socket, SLOT(deleteLater()));
+
+ // Wait for a bit *after* first response, then close server if no useable data has arrived
+ // Helps with implicit flow, where a URL fragment may need processed by local user-agent and
+ // sent as secondary query string callback, or additional requests make it through first,
+ // like for favicons, etc., before such secondary callbacks are fired
+ QTimer *timer = new QTimer(socket);
+ timer->setObjectName("timeoutTimer");
+ connect(timer, SIGNAL(timeout()), this, SLOT(closeServer()));
+ timer->setSingleShot(true);
+ timer->setInterval(timeout() * 1000);
+ connect(socket, SIGNAL(readyRead()), timer, SLOT(start()));
+}
+
+void ReplyServer::onBytesReady() {
+ if (!isListening()) {
+ // server has been closed, stop processing queued connections
+ return;
+ }
+ qDebug() << "O2ReplyServer::onBytesReady: Processing request";
+ // NOTE: on first call, the timeout timer is started
+ QTcpSocket *socket = qobject_cast<QTcpSocket *>(sender());
+ if (!socket) {
+ qWarning() << "O2ReplyServer::onBytesReady: No socket available";
+ return;
+ }
+ QByteArray reply;
+ reply.append("HTTP/1.0 200 OK \r\n");
+ reply.append("Content-Type: text/html; charset=\"utf-8\"\r\n");
+ reply.append(QString("Content-Length: %1\r\n\r\n").arg(replyContent_.size()).toLatin1());
+ reply.append(replyContent_);
+ socket->write(reply);
+ qDebug() << "O2ReplyServer::onBytesReady: Sent reply";
+
+ QByteArray data = socket->readAll();
+ QMap<QString, QString> queryParams = parseQueryParams(&data);
+ if (queryParams.isEmpty()) {
+ if (tries_ < maxtries_ ) {
+ qDebug() << "O2ReplyServer::onBytesReady: No query params found, waiting for more callbacks";
+ ++tries_;
+ return;
+ } else {
+ tries_ = 0;
+ qWarning() << "O2ReplyServer::onBytesReady: No query params found, maximum callbacks received";
+ closeServer(socket, false);
+ return;
+ }
+ }
+ if (!uniqueState_.isEmpty() && !queryParams.contains(QString(OAUTH2_STATE))) {
+ qDebug() << "O2ReplyServer::onBytesReady: Malicious or service request";
+ closeServer(socket, true);
+ return; // Malicious or service (e.g. favicon.ico) request
+ }
+ qDebug() << "O2ReplyServer::onBytesReady: Query params found, closing server";
+ closeServer(socket, true);
+ emit verificationReceived(queryParams);
+}
+
+QMap<QString, QString> ReplyServer::parseQueryParams(QByteArray *data) {
+ qDebug() << "O2ReplyServer::parseQueryParams";
+
+ //qDebug() << QString("O2ReplyServer::parseQueryParams data:\n%1").arg(QString(*data));
+
+ QString splitGetLine = QString(*data).split("\r\n").first();
+ splitGetLine.remove("GET ");
+ splitGetLine.remove("HTTP/1.1");
+ splitGetLine.remove("\r\n");
+ splitGetLine.prepend("http://localhost");
+ QUrl getTokenUrl(splitGetLine);
+
+ QList< QPair<QString, QString> > tokens;
+ QUrlQuery query(getTokenUrl);
+ tokens = query.queryItems();
+ QMap<QString, QString> queryParams;
+ QPair<QString, QString> tokenPair;
+ foreach (tokenPair, tokens) {
+ // FIXME: We are decoding key and value again. This helps with Google OAuth, but is it mandated by the standard?
+ QString key = QUrl::fromPercentEncoding(QByteArray().append(tokenPair.first.trimmed().toLatin1()));
+ QString value = QUrl::fromPercentEncoding(QByteArray().append(tokenPair.second.trimmed().toLatin1()));
+ queryParams.insert(key, value);
+ }
+ return queryParams;
+}
+
+void ReplyServer::closeServer(QTcpSocket *socket, bool hasparameters)
+{
+ if (!isListening()) {
+ return;
+ }
+
+ qDebug() << "O2ReplyServer::closeServer: Initiating";
+ int port = serverPort();
+
+ if (!socket && sender()) {
+ QTimer *timer = qobject_cast<QTimer*>(sender());
+ if (timer) {
+ qWarning() << "O2ReplyServer::closeServer: Closing due to timeout";
+ timer->stop();
+ socket = qobject_cast<QTcpSocket *>(timer->parent());
+ timer->deleteLater();
+ }
+ }
+ if (socket) {
+ QTimer *timer = socket->findChild<QTimer*>("timeoutTimer");
+ if (timer) {
+ qDebug() << "O2ReplyServer::closeServer: Stopping socket's timeout timer";
+ timer->stop();
+ }
+ socket->disconnectFromHost();
+ }
+ close();
+ qDebug() << "O2ReplyServer::closeServer: Closed, no longer listening on port" << port;
+ emit serverClosed(hasparameters);
+}
+
+QByteArray ReplyServer::replyContent() {
+ return replyContent_;
+}
+
+void ReplyServer::setReplyContent(const QByteArray &value) {
+ replyContent_ = value;
+}
+
+int ReplyServer::timeout()
+{
+ return timeout_;
+}
+
+void ReplyServer::setTimeout(int timeout)
+{
+ timeout_ = timeout;
+}
+
+int ReplyServer::callbackTries()
+{
+ return maxtries_;
+}
+
+void ReplyServer::setCallbackTries(int maxtries)
+{
+ maxtries_ = maxtries;
+}
+
+QString ReplyServer::uniqueState()
+{
+ return uniqueState_;
+}
+
+void ReplyServer::setUniqueState(const QString &state)
+{
+ uniqueState_ = state;
+}
+
+}
diff --git a/libraries/katabasis/src/Requestor.cpp b/libraries/katabasis/src/Requestor.cpp
new file mode 100644
index 00000000..7b6d2679
--- /dev/null
+++ b/libraries/katabasis/src/Requestor.cpp
@@ -0,0 +1,304 @@
+#include <cassert>
+
+#include <QDebug>
+#include <QTimer>
+#include <QBuffer>
+#include <QUrlQuery>
+
+#include "katabasis/Requestor.h"
+#include "katabasis/OAuth2.h"
+#include "katabasis/Globals.h"
+
+namespace Katabasis {
+
+Requestor::Requestor(QNetworkAccessManager *manager, OAuth2 *authenticator, QObject *parent): QObject(parent), reply_(NULL), status_(Idle), addAccessTokenInQuery_(true), rawData_(false) {
+ manager_ = manager;
+ authenticator_ = authenticator;
+ if (authenticator) {
+ timedReplies_.setIgnoreSslErrors(authenticator->ignoreSslErrors());
+ }
+ qRegisterMetaType<QNetworkReply::NetworkError>("QNetworkReply::NetworkError");
+ connect(authenticator, &OAuth2::refreshFinished, this, &Requestor::onRefreshFinished, Qt::QueuedConnection);
+}
+
+Requestor::~Requestor() {
+}
+
+void Requestor::setAddAccessTokenInQuery(bool value) {
+ addAccessTokenInQuery_ = value;
+}
+
+void Requestor::setAccessTokenInAuthenticationHTTPHeaderFormat(const QString &value) {
+ accessTokenInAuthenticationHTTPHeaderFormat_ = value;
+}
+
+int Requestor::get(const QNetworkRequest &req, int timeout/* = 60*1000*/) {
+ if (-1 == setup(req, QNetworkAccessManager::GetOperation)) {
+ return -1;
+ }
+ reply_ = manager_->get(request_);
+ timedReplies_.add(new Reply(reply_, timeout));
+ connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection);
+ return id_;
+}
+
+int Requestor::post(const QNetworkRequest &req, const QByteArray &data, int timeout/* = 60*1000*/) {
+ if (-1 == setup(req, QNetworkAccessManager::PostOperation)) {
+ return -1;
+ }
+ rawData_ = true;
+ data_ = data;
+ reply_ = manager_->post(request_, data_);
+ timedReplies_.add(new Reply(reply_, timeout));
+ connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64)));
+ return id_;
+}
+
+int Requestor::post(const QNetworkRequest & req, QHttpMultiPart* data, int timeout/* = 60*1000*/)
+{
+ if (-1 == setup(req, QNetworkAccessManager::PostOperation)) {
+ return -1;
+ }
+ rawData_ = false;
+ multipartData_ = data;
+ reply_ = manager_->post(request_, multipartData_);
+ multipartData_->setParent(reply_);
+ timedReplies_.add(new Reply(reply_, timeout));
+ connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64)));
+ return id_;
+}
+
+int Requestor::put(const QNetworkRequest &req, const QByteArray &data, int timeout/* = 60*1000*/) {
+ if (-1 == setup(req, QNetworkAccessManager::PutOperation)) {
+ return -1;
+ }
+ rawData_ = true;
+ data_ = data;
+ reply_ = manager_->put(request_, data_);
+ timedReplies_.add(new Reply(reply_, timeout));
+ connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64)));
+ return id_;
+}
+
+int Requestor::put(const QNetworkRequest & req, QHttpMultiPart* data, int timeout/* = 60*1000*/)
+{
+ if (-1 == setup(req, QNetworkAccessManager::PutOperation)) {
+ return -1;
+ }
+ rawData_ = false;
+ multipartData_ = data;
+ reply_ = manager_->put(request_, multipartData_);
+ multipartData_->setParent(reply_);
+ timedReplies_.add(new Reply(reply_, timeout));
+ connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64)));
+ return id_;
+}
+
+int Requestor::customRequest(const QNetworkRequest &req, const QByteArray &verb, const QByteArray &data, int timeout/* = 60*1000*/)
+{
+ (void)timeout;
+
+ if (-1 == setup(req, QNetworkAccessManager::CustomOperation, verb)) {
+ return -1;
+ }
+ data_ = data;
+ QBuffer * buffer = new QBuffer;
+ buffer->setData(data_);
+ reply_ = manager_->sendCustomRequest(request_, verb, buffer);
+ buffer->setParent(reply_);
+ timedReplies_.add(new Reply(reply_));
+ connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64)));
+ return id_;
+}
+
+int Requestor::head(const QNetworkRequest &req, int timeout/* = 60*1000*/)
+{
+ if (-1 == setup(req, QNetworkAccessManager::HeadOperation)) {
+ return -1;
+ }
+ reply_ = manager_->head(request_);
+ timedReplies_.add(new Reply(reply_, timeout));
+ connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection);
+ return id_;
+}
+
+void Requestor::onRefreshFinished(QNetworkReply::NetworkError error) {
+ if (status_ != Requesting) {
+ qWarning() << "O2Requestor::onRefreshFinished: No pending request";
+ return;
+ }
+ if (QNetworkReply::NoError == error) {
+ QTimer::singleShot(100, this, &Requestor::retry);
+ } else {
+ error_ = error;
+ QTimer::singleShot(10, this, &Requestor::finish);
+ }
+}
+
+void Requestor::onRequestFinished() {
+ if (status_ == Idle) {
+ return;
+ }
+ if (reply_ != qobject_cast<QNetworkReply *>(sender())) {
+ return;
+ }
+ if (reply_->error() == QNetworkReply::NoError) {
+ QTimer::singleShot(10, this, SLOT(finish()));
+ }
+}
+
+void Requestor::onRequestError(QNetworkReply::NetworkError error) {
+ qWarning() << "O2Requestor::onRequestError: Error" << (int)error;
+ if (status_ == Idle) {
+ return;
+ }
+ if (reply_ != qobject_cast<QNetworkReply *>(sender())) {
+ return;
+ }
+ int httpStatus = reply_->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ qWarning() << "O2Requestor::onRequestError: HTTP status" << httpStatus << reply_->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString();
+ if ((status_ == Requesting) && (httpStatus == 401)) {
+ // Call OAuth2::refresh. Note the O2 instance might live in a different thread
+ if (QMetaObject::invokeMethod(authenticator_, "refresh")) {
+ return;
+ }
+ qCritical() << "O2Requestor::onRequestError: Invoking remote refresh failed";
+ }
+ error_ = error;
+ QTimer::singleShot(10, this, SLOT(finish()));
+}
+
+void Requestor::onUploadProgress(qint64 uploaded, qint64 total) {
+ if (status_ == Idle) {
+ qWarning() << "O2Requestor::onUploadProgress: No pending request";
+ return;
+ }
+ if (reply_ != qobject_cast<QNetworkReply *>(sender())) {
+ return;
+ }
+ // Restart timeout because request in progress
+ Reply *o2Reply = timedReplies_.find(reply_);
+ if(o2Reply)
+ o2Reply->start();
+ emit uploadProgress(id_, uploaded, total);
+}
+
+int Requestor::setup(const QNetworkRequest &req, QNetworkAccessManager::Operation operation, const QByteArray &verb) {
+ static int currentId;
+
+ if (status_ != Idle) {
+ qWarning() << "O2Requestor::setup: Another request pending";
+ return -1;
+ }
+
+ request_ = req;
+ operation_ = operation;
+ id_ = currentId++;
+ url_ = req.url();
+
+ QUrl url = url_;
+ if (addAccessTokenInQuery_) {
+ QUrlQuery query(url);
+ query.addQueryItem(OAUTH2_ACCESS_TOKEN, authenticator_->token());
+ url.setQuery(query);
+ }
+
+ request_.setUrl(url);
+
+ // If the service require the access token to be sent as a Authentication HTTP header, we add the access token.
+ if (!accessTokenInAuthenticationHTTPHeaderFormat_.isEmpty()) {
+ request_.setRawHeader(HTTP_AUTHORIZATION_HEADER, accessTokenInAuthenticationHTTPHeaderFormat_.arg(authenticator_->token()).toLatin1());
+ }
+
+ if (!verb.isEmpty()) {
+ request_.setRawHeader(HTTP_HTTP_HEADER, verb);
+ }
+
+ status_ = Requesting;
+ error_ = QNetworkReply::NoError;
+ return id_;
+}
+
+void Requestor::finish() {
+ QByteArray data;
+ if (status_ == Idle) {
+ qWarning() << "O2Requestor::finish: No pending request";
+ return;
+ }
+ data = reply_->readAll();
+ status_ = Idle;
+ timedReplies_.remove(reply_);
+ reply_->disconnect(this);
+ reply_->deleteLater();
+ QList<QNetworkReply::RawHeaderPair> headers = reply_->rawHeaderPairs();
+ emit finished(id_, error_, data, headers);
+}
+
+void Requestor::retry() {
+ if (status_ != Requesting) {
+ qWarning() << "O2Requestor::retry: No pending request";
+ return;
+ }
+ timedReplies_.remove(reply_);
+ reply_->disconnect(this);
+ reply_->deleteLater();
+ QUrl url = url_;
+ if (addAccessTokenInQuery_) {
+ QUrlQuery query(url);
+ query.addQueryItem(OAUTH2_ACCESS_TOKEN, authenticator_->token());
+ url.setQuery(query);
+ }
+ request_.setUrl(url);
+
+ // If the service require the access token to be sent as a Authentication HTTP header,
+ // we update the access token when retrying.
+ if(!accessTokenInAuthenticationHTTPHeaderFormat_.isEmpty()) {
+ request_.setRawHeader(HTTP_AUTHORIZATION_HEADER, accessTokenInAuthenticationHTTPHeaderFormat_.arg(authenticator_->token()).toLatin1());
+ }
+
+ status_ = ReRequesting;
+ switch (operation_) {
+ case QNetworkAccessManager::GetOperation:
+ reply_ = manager_->get(request_);
+ break;
+ case QNetworkAccessManager::PostOperation:
+ reply_ = rawData_ ? manager_->post(request_, data_) : manager_->post(request_, multipartData_);
+ break;
+ case QNetworkAccessManager::CustomOperation:
+ {
+ QBuffer * buffer = new QBuffer;
+ buffer->setData(data_);
+ reply_ = manager_->sendCustomRequest(request_, request_.rawHeader(HTTP_HTTP_HEADER), buffer);
+ buffer->setParent(reply_);
+ }
+ break;
+ case QNetworkAccessManager::PutOperation:
+ reply_ = rawData_ ? manager_->post(request_, data_) : manager_->put(request_, multipartData_);
+ break;
+ case QNetworkAccessManager::HeadOperation:
+ reply_ = manager_->head(request_);
+ break;
+ default:
+ assert(!"Unspecified operation for request");
+ reply_ = manager_->get(request_);
+ break;
+ }
+ timedReplies_.add(reply_);
+ connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection);
+ connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64)));
+}
+
+}