1// SPDX-FileCopyrightText: 2020 Kitsune Ral <kitsune-ral@users.sf.net>
2// SPDX-License-Identifier: LGPL-2.1-or-later
3
4#include "ssosession.h"
5
6#include "connection.h"
7#include "logging_categories_p.h"
8
9#include "csapi/sso_login_redirect.h"
10
11#include <QtCore/QCoreApplication>
12#include <QtCore/QStringBuilder>
13#include <QtNetwork/QTcpServer>
14#include <QtNetwork/QTcpSocket>
15
16#include <QNetworkProxy>
17
18using namespace Quotient;
19
20class Q_DECL_HIDDEN SsoSession::Private {
21public:
22 Private(SsoSession* q, QString initialDeviceName, QString deviceId,
23 Connection* connection)
24 : initialDeviceName(std::move(initialDeviceName))
25 , deviceId(std::move(deviceId))
26 , connection(connection)
27 {
28 auto* server = new QTcpServer(q);
29 // User might apply an application level proxy and we don't need them here.
30 server->setProxy(QNetworkProxy::NoProxy);
31 if (!server->listen())
32 qCritical(catFunc: MAIN)
33 << "Could not open the port, SSO callback won't work:" << server->errorString();
34 // The "/returnToApplication" part is just a hint for the end-user,
35 // the callback will work without it equally well.
36 callbackUrl = QStringLiteral("http://localhost:%1/returnToApplication")
37 .arg(a: server->serverPort());
38 ssoUrl = connection->getUrlForApi<RedirectToSSOJob>(jobArgs&: callbackUrl);
39
40 QObject::connect(sender: server, signal: &QTcpServer::newConnection, context: q, slot: [this, q, server] {
41 qCDebug(MAIN) << "SSO callback initiated";
42 socket = server->nextPendingConnection();
43 server->close();
44 QObject::connect(sender: socket, signal: &QTcpSocket::readyRead, context: socket, slot: [this] {
45 requestData.append(a: socket->readAll());
46 if (!socket->atEnd() && !requestData.endsWith(bv: "\r\n\r\n")) {
47 qDebug(catFunc: MAIN) << "Incomplete request, waiting for more data";
48 return;
49 }
50 processCallback();
51 });
52 QObject::connect(sender: socket, signal: &QTcpSocket::disconnected, context: socket,
53 slot: &QTcpSocket::deleteLater);
54 QObject::connect(sender: socket, signal: &QObject::destroyed, context: q,
55 slot: &QObject::deleteLater);
56 });
57 qCDebug(MAIN) << "SSO session constructed";
58 }
59 ~Private() { qCDebug(MAIN) << "SSO session deconstructed"; }
60 Q_DISABLE_COPY_MOVE(Private)
61
62 void processCallback();
63 void sendHttpResponse(const QByteArray& code, const QByteArray& msg);
64 void onError(const QByteArray& code, const QString& errorMsg);
65
66 QString initialDeviceName;
67 QString deviceId;
68 Connection* connection;
69 QString callbackUrl {};
70 QUrl ssoUrl {};
71 QTcpSocket* socket = nullptr;
72 QByteArray requestData {};
73};
74
75SsoSession::SsoSession(Connection* connection, const QString& initialDeviceName,
76 const QString& deviceId)
77 : QObject(connection)
78 , d(makeImpl<Private>(args: this, args: initialDeviceName, args: deviceId, args&: connection))
79{}
80
81QUrl SsoSession::ssoUrl() const { return d->ssoUrl; }
82
83QUrl SsoSession::callbackUrl() const { return QUrl(d->callbackUrl); }
84
85void SsoSession::Private::processCallback()
86{
87 // https://matrix.org/docs/guides/sso-for-client-developers
88 // Inspired by Clementine's src/internet/core/localredirectserver.cpp
89 // (see at https://github.com/clementine-player/Clementine/)
90 const auto& requestParts = requestData.split(sep: ' ');
91 if (requestParts.size() < 2 || requestParts[1].isEmpty()) {
92 onError(code: "400 Bad Request", errorMsg: tr(s: "Malformed single sign-on callback"));
93 return;
94 }
95 const auto& QueryItemName = QStringLiteral("loginToken");
96 QUrlQuery query { QUrl(QString::fromUtf8(ba: requestParts[1])).query() };
97 if (!query.hasQueryItem(key: QueryItemName)) {
98 onError(code: "400 Bad Request", errorMsg: tr(s: "No login token in SSO callback"));
99 return;
100 }
101 qCDebug(MAIN) << "Found the token in SSO callback, logging in";
102 connection->loginWithToken(loginToken: query.queryItemValue(key: QueryItemName),
103 initialDeviceName, deviceId);
104 connect(sender: connection, signal: &Connection::connected, context: socket, slot: [this] {
105 const auto msg =
106 tr(s: "The application '%1' has successfully logged in as a user %2 "
107 "with device id %3. This window can be closed. Thank you.\r\n")
108 .arg(args: QCoreApplication::applicationName(), args: connection->userId(),
109 args: connection->deviceId());
110 sendHttpResponse(code: "200 OK", msg: msg.toHtmlEscaped().toUtf8());
111 socket->disconnectFromHost();
112 });
113 connect(sender: connection, signal: &Connection::loginError, context: socket, slot: [this] {
114 onError(code: "401 Unauthorised", errorMsg: tr(s: "Login failed"));
115 });
116}
117
118void SsoSession::Private::sendHttpResponse(const QByteArray& code,
119 const QByteArray& msg)
120{
121 socket->write(data: "HTTP/1.0 ");
122 socket->write(data: code);
123 socket->write(data: "\r\n"
124 "Content-type: text/html;charset=UTF-8\r\n"
125 "\r\n\r\n");
126 socket->write(data: msg);
127 socket->write(data: "\r\n");
128}
129
130void SsoSession::Private::onError(const QByteArray& code,
131 const QString& errorMsg)
132{
133 qCWarning(MAIN).nospace() << errorMsg;
134 sendHttpResponse(code, msg: "<h3>" + errorMsg.toUtf8() + "</h3>");
135 // [kitsune] Yeah, I know, dirty. Maybe the "right" way would be to have
136 // an intermediate signal but that seems just a fight for purity.
137 emit connection->loginError(message: errorMsg, details: QString::fromUtf8(ba: requestData));
138 socket->disconnectFromHost();
139}
140