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}}
175 .toString(QUrl::FullyEncoded)
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()}}
262 .toString(QUrl::FullyEncoded)
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)
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)
void newConnection()
FullyEncoded
This file is part of the KDE documentation.
Documentation copyright © 1996-2025 The KDE developers.
Generated on Fri Jan 24 2025 11:53:49 by doxygen 1.13.2 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.