MailTransport

outlookoauthtokenrequester.cpp
1/*
2 SPDX-FileCopyrightText: 2024 Daniel Vrátil <dvratil@kde.org>
3
4 SPDX-License-Identifier: LGPL-2.0-or-later
5*/
6
7#include "outlookoauthtokenrequester.h"
8#include "mailtransport_debug.h"
9
10#include <QCryptographicHash>
11#include <QDesktopServices>
12#include <QHostAddress>
13#include <QJsonDocument>
14#include <QJsonObject>
15#include <QMap>
16#include <QNetworkAccessManager>
17#include <QNetworkReply>
18#include <QNetworkRequest>
19#include <QRandomGenerator64>
20#include <QTcpServer>
21#include <QTcpSocket>
22#include <QUrl>
23#include <QUrlQuery>
24
25using namespace MailTransport;
26
27TokenResult::TokenResult(ErrorCode errorCode, const QString &errorText)
28 : mErrorCode(errorCode)
29 , mErrorText(errorText)
30{
31}
32
33TokenResult::TokenResult(const QString &accessToken, const QString &refreshToken)
34 : mAccessToken(accessToken)
35 , mRefreshToken(refreshToken)
36{
37}
38
39QString TokenResult::accessToken() const
40{
41 return mAccessToken;
42}
43
44QString TokenResult::refreshToken() const
45{
46 return mRefreshToken;
47}
48
49bool TokenResult::hasError() const
50{
51 return mErrorCode != 0;
52}
53
54TokenResult::ErrorCode TokenResult::errorCode() const
55{
56 return mErrorCode;
57}
58
59QString TokenResult::errorText() const
60{
61 return mErrorText;
62}
63
64Q_DECLARE_METATYPE(TokenResult);
65
66/*********************************************************************/
67
68namespace MailTransport
69{
70
71/// Helper class to generate PKCE verifier and challenge (RFC 7636)
72class PKCE
73{
74public:
75 explicit PKCE()
76 {
77 mVerifier = generateRandomString(128);
78 mChallenge = generateChallenge(mVerifier);
79 }
80
81 QString challenge() const
82 {
83 return mChallenge;
84 }
85
86 QString verifier() const
87 {
88 return mVerifier;
89 }
90
91private:
92 QString generateRandomString(std::size_t length)
93 {
94 static const char charset[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._`~";
95
96 auto generator = QRandomGenerator::securelySeeded();
97 QString result;
98 result.reserve(length);
99 for (size_t i = 0; i < length; ++i) {
100 const int idx = generator.bounded(static_cast<int>(sizeof(charset) - 1));
101 result.append(QChar::fromLatin1(charset[idx]));
102 }
103 return result;
104 }
105
106 QString generateChallenge(const QString &verifier)
107 {
108 const auto sha256 = QCryptographicHash::hash(verifier.toUtf8(), QCryptographicHash::Sha256);
110 }
111
112private:
113 QString mVerifier;
114 QString mChallenge;
115};
116
117} // namespace
118
119/*********************************************************************/
120
121OutlookOAuthTokenRequester::OutlookOAuthTokenRequester(const QString &clientId, const QString &tenantId, const QStringList &scopes, QObject *parent)
122 : QObject(parent)
123 , mClientId(clientId)
124 , mTenantId(tenantId)
125 , mScopes(scopes)
126 , mPkce(std::make_unique<PKCE>())
127{
128}
129
130OutlookOAuthTokenRequester::~OutlookOAuthTokenRequester() = default;
131
132void OutlookOAuthTokenRequester::requestToken(const QString &usernameHint)
133{
134 qCDebug(MAILTRANSPORT_LOG) << "Requesting new Outlook OAuth2 access token";
135
136 auto redirectUri = startLocalHttpServer();
137 if (!redirectUri.has_value()) {
138 Q_EMIT finished({TokenResult::InternalError, QStringLiteral("Failed to start local HTTP server to receive Outlook OAuth2 authorization code")});
139 }
140 mRedirectUri = *redirectUri;
141
142 QUrl url(QStringLiteral("https://login.microsoftonline.com/%1/oauth2/v2.0/authorize").arg(mTenantId));
143 QUrlQuery query{{QStringLiteral("client_id"), mClientId},
144 {QStringLiteral("redirect_uri"), mRedirectUri.toString()},
145 {QStringLiteral("response_type"), QStringLiteral("code")},
146 {QStringLiteral("response_mode"), QStringLiteral("query")},
147 {QStringLiteral("scope"), mScopes.join(u' ')},
148 {QStringLiteral("code_challenge"), mPkce->challenge()},
149 {QStringLiteral("code_challenge_method"), QStringLiteral("S256")}};
150 if (!usernameHint.isEmpty()) {
151 query.addQueryItem(QStringLiteral("login_hint"), usernameHint);
152 } else {
153 query.addQueryItem(QStringLiteral("prompt"), QStringLiteral("select_account"));
154 }
155 url.setQuery(query);
156
157 qCDebug(MAILTRANSPORT_LOG) << "Browser opened, waiting for Outlook OAuth2 authorization code...";
159}
160
161void OutlookOAuthTokenRequester::refreshToken(const QString &refreshToken)
162{
163 qCDebug(MAILTRANSPORT_LOG) << "Refreshing Outlook OAuth2 access token";
164
165 QUrl url(QStringLiteral("https://login.microsoftonline.com/%1/oauth2/v2.0/token").arg(mTenantId));
166
167 QNetworkRequest request{url};
168 request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
169 mNam = std::make_unique<QNetworkAccessManager>();
170 auto *reply = mNam->post(request,
171 QUrlQuery{{QStringLiteral("client_id"), mClientId},
172 {QStringLiteral("grant_type"), QStringLiteral("refresh_token")},
173 {QStringLiteral("scope"), mScopes.join(u' ')},
174 {QStringLiteral("refresh_token"), refreshToken}}
176 .toUtf8());
177 connect(reply, &QNetworkReply::finished, this, [this, reply]() {
178 handleTokenResponse(reply, true);
179 });
180}
181
182std::optional<QUrl> OutlookOAuthTokenRequester::startLocalHttpServer()
183{
184 mHttpServer = std::make_unique<QTcpServer>();
185 connect(mHttpServer.get(), &QTcpServer::newConnection, this, &OutlookOAuthTokenRequester::handleNewConnection);
186 if (!mHttpServer->listen(QHostAddress::LocalHost)) {
187 return {};
188 }
189 qCDebug(MAILTRANSPORT_LOG) << "Local Outlook OAuth2 server listening on port" << mHttpServer->serverPort();
190
191 return QUrl(QStringLiteral("http://localhost:%1").arg(mHttpServer->serverPort()));
192}
193
194void OutlookOAuthTokenRequester::handleNewConnection()
195{
196 qCDebug(MAILTRANSPORT_LOG) << "New incoming connection from Outlook OAuth2";
197 mSocket = std::unique_ptr<QTcpSocket>(mHttpServer->nextPendingConnection());
198 connect(mSocket.get(), &QTcpSocket::readyRead, this, &OutlookOAuthTokenRequester::handleSocketReadyRead);
199}
200
201void OutlookOAuthTokenRequester::handleSocketReadyRead()
202{
203 auto request = mSocket->readLine();
204 mSocket->readAll(); // read the rest of data and discard it
205
206 sendResponseToBrowserAndCloseSocket();
207
208 if (!request.startsWith("GET /?") && !request.endsWith("HTTP/1.1")) {
209 Q_EMIT finished({TokenResult::InvalidAuthorizationResponse, QStringLiteral("Invalid authorization response from server")});
210 return;
211 }
212
213 // Remove verb and protocol from the request line
214 request.remove(0, sizeof("GET ") - 1);
215 request.truncate(request.size() - sizeof(" HTTP/1.1") - 1);
216 // Prefix it with protocol and domain so that it's a full URL that we can parse
217 request.prepend("http://localhost");
218
219 // Try to parse the URL
220 QUrl url(QString::fromUtf8(request));
221 if (!url.isValid()) {
222 qCWarning(MAILTRANSPORT_LOG) << "Failed to extract valid URL from initial HTTP request line from Outlook OAuth2:" << request;
223 Q_EMIT finished({TokenResult::InvalidAuthorizationResponse, QStringLiteral("Invalid authorization response from server")});
224 return;
225 }
226
227 // Extract code
228 const QUrlQuery query(url);
229 if (query.hasQueryItem(QStringLiteral("error"))) {
230 const auto error = query.queryItemValue(QStringLiteral("error"));
231 const auto errorDescription = query.queryItemValue(QStringLiteral("error_description"));
232 qCWarning(MAILTRANSPORT_LOG) << "Authorization server returned error:" << error << errorDescription;
233 Q_EMIT finished({TokenResult::AuthorizationFailed, errorDescription});
234 return;
235 }
236
237 const auto code = query.queryItemValue(QStringLiteral("code"));
238 if (code.isEmpty()) {
239 qCWarning(MAILTRANSPORT_LOG) << "Failed to extract authorization code from Outlook OAuth2 response:" << request;
240 Q_EMIT finished({TokenResult::InvalidAuthorizationResponse, QStringLiteral("Invalid authorization response from server")});
241 return;
242 }
243
244 qCDebug(MAILTRANSPORT_LOG) << "Extracted Outlook OAuth2 autorization token from response, requesting access token...";
245 requestIdToken(code);
246}
247
248void OutlookOAuthTokenRequester::requestIdToken(const QString &code)
249{
250 QUrl url(QStringLiteral("https://login.microsoftonline.com/%1/oauth2/v2.0/token").arg(mTenantId));
251
252 QNetworkRequest request{url};
253 request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
254 mNam = std::make_unique<QNetworkAccessManager>();
255 auto *reply = mNam->post(request,
256 QUrlQuery{{QStringLiteral("client_id"), mClientId},
257 {QStringLiteral("scope"), mScopes.join(u' ')},
258 {QStringLiteral("code"), code},
259 {QStringLiteral("redirect_uri"), mRedirectUri.toString()},
260 {QStringLiteral("grant_type"), QStringLiteral("authorization_code")},
261 {QStringLiteral("code_verifier"), mPkce->verifier()}}
263 .toUtf8());
264 connect(reply, &QNetworkReply::finished, this, [this, reply]() {
265 handleTokenResponse(reply);
266 });
267 qCDebug(MAILTRANSPORT_LOG) << "Requested Outlook OAuth2 access token, waiting for response...";
268}
269
270void OutlookOAuthTokenRequester::handleTokenResponse(QNetworkReply *reply, bool isTokenRefresh)
271{
272 const auto responseData = reply->readAll();
273 reply->deleteLater();
274
275 const auto response = QJsonDocument::fromJson(responseData);
276 if (!response.isObject()) {
277 qCWarning(MAILTRANSPORT_LOG) << "Failed to parse token response:" << responseData;
278 Q_EMIT finished({TokenResult::InvalidAuthorizationResponse, QStringLiteral("Failed to parse token response")});
279 return;
280 }
281
282 if (response[QStringView{u"error"}].isString()) {
283 const auto error = response[QStringView{u"error"}].toString();
284 const auto errorDescription = response[QStringView{u"error_description"}].toString();
285 qCWarning(MAILTRANSPORT_LOG) << "Outlook OAuth2 authorization server returned error:" << error << errorDescription;
286
287 if (isTokenRefresh && error == u"invalid_grant") {
288 qCDebug(MAILTRANSPORT_LOG) << "Outlook OAuth2 refresh token is invalid, requesting new token...";
289 requestToken();
290 } else {
291 Q_EMIT finished({TokenResult::AuthorizationFailed, errorDescription});
292 }
293 return;
294 }
295
296 const auto accessToken = response[QStringView{u"access_token"}].toString();
297 const auto refreshToken = response[QStringView{u"refresh_token"}].toString();
298
299 qCDebug(MAILTRANSPORT_LOG) << "Received Outlook OAuth2 access and refresh tokens";
300
301 Q_EMIT finished({accessToken, refreshToken});
302}
303
304void OutlookOAuthTokenRequester::sendResponseToBrowserAndCloseSocket()
305{
306 const auto response =
307 "HTTP/1.1 200 OK\r\n"
308 "Content-Type: text/html; charset=utf-8\r\n"
309 "Connection: close\r\n"
310 "\r\n"
311 "<html>"
312 "<head><title>KDE PIM Authorization</title></head>"
313 "<body>"
314 "<h1>You can close the browser window now and return to the application.</h1>"
315 "</body>"
316 "</html>\r\n\r\n";
317
318 mSocket->write(response);
319 mSocket->flush();
320 mSocket->close();
321
322 mSocket.release()->deleteLater();
323
324 mHttpServer->close();
325 mHttpServer.release()->deleteLater();
326
327 qCDebug(MAILTRANSPORT_LOG) << "Sent HTTP OK response to browser and closed our local HTTP server.";
328}
329
330#include "moc_outlookoauthtokenrequester.cpp"
KSERVICE_EXPORT KService::List query(FilterFunc filterFunc)
void error(QWidget *parent, const QString &text, const QString &title, const KGuiItem &buttonOk, Options options=Notify)
QChar fromLatin1(char c)
QByteArray hash(QByteArrayView data, Algorithm method)
bool openUrl(const QUrl &url)
QByteArray readAll()
void readyRead()
QJsonDocument fromJson(const QByteArray &json, QJsonParseError *error)
void setHeader(KnownHeaders header, const QVariant &value)
Q_EMITQ_EMIT
QMetaObject::Connection connect(const QObject *sender, PointerToMemberFunction signal, Functor functor)
void deleteLater()
QRandomGenerator securelySeeded()
QString & append(QChar ch)
QString fromLatin1(QByteArrayView str)
QString fromUtf8(QByteArrayView str)
bool isEmpty() const const
void reserve(qsizetype size)
QByteArray toUtf8() const const
QString join(QChar separator) const const
void newConnection()
FullyEncoded
QString toString(FormattingOptions options) const const
QString toString(QUrl::ComponentFormattingOptions encoding) const const
This file is part of the KDE documentation.
Documentation copyright © 1996-2024 The KDE developers.
Generated on Mon Nov 4 2024 16:35:38 by doxygen 1.12.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.