diff options
| -rw-r--r-- | .cmake.conf | 2 | ||||
| -rw-r--r-- | dependencies.yaml | 2 | ||||
| -rw-r--r-- | src/oauth/qabstractoauth.cpp | 33 | ||||
| -rw-r--r-- | src/oauth/qabstractoauth2.cpp | 5 | ||||
| -rw-r--r-- | src/oauth/qoauth2authorizationcodeflow.cpp | 32 | ||||
| -rw-r--r-- | src/oauth/qoauthhttpserverreplyhandler.cpp | 21 | ||||
| -rw-r--r-- | src/oauth/qoauthhttpserverreplyhandler_p.h | 3 | ||||
| -rw-r--r-- | tests/auto/abstractoauth/tst_abstractoauth.cpp | 49 | ||||
| -rw-r--r-- | tests/auto/oauth2/tst_oauth2.cpp | 120 | ||||
| -rw-r--r-- | tests/auto/oauthhttpserverreplyhandler/tst_oauthhttpserverreplyhandler.cpp | 29 |
10 files changed, 271 insertions, 25 deletions
diff --git a/.cmake.conf b/.cmake.conf index 1aa29ce..98c2813 100644 --- a/.cmake.conf +++ b/.cmake.conf @@ -1,3 +1,3 @@ -set(QT_REPO_MODULE_VERSION "6.5.6") +set(QT_REPO_MODULE_VERSION "6.5.7") set(QT_REPO_MODULE_PRERELEASE_VERSION_SEGMENT "alpha1") set(QT_EXTRA_INTERNAL_TARGET_DEFINES "QT_NO_AS_CONST=1") diff --git a/dependencies.yaml b/dependencies.yaml index 0a37caa..0743c54 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -1,4 +1,4 @@ dependencies: ../tqtc-qtbase: - ref: 5d8e9a8415562ba004b38508d91e1fa0254c17d3 + ref: fc0e66eefe3a08428ca4a6e92c66f37ac126d3c4 required: true diff --git a/src/oauth/qabstractoauth.cpp b/src/oauth/qabstractoauth.cpp index e6589bb..bb7c16c 100644 --- a/src/oauth/qabstractoauth.cpp +++ b/src/oauth/qabstractoauth.cpp @@ -22,8 +22,6 @@ #include <QtCore/qrandom.h> #include <QtCore/private/qlocking_p.h> -#include <random> - QT_BEGIN_NAMESPACE /*! @@ -264,20 +262,27 @@ void QAbstractOAuthPrivate::setStatus(QAbstractOAuth::Status newStatus) } } -Q_CONSTINIT static QBasicMutex prngMutex; -Q_GLOBAL_STATIC_WITH_ARGS(std::mt19937, prng, (*QRandomGenerator::system())) - QByteArray QAbstractOAuthPrivate::generateRandomString(quint8 length) { - constexpr char characters[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - std::uniform_int_distribution<int> distribution(0, sizeof(characters) - 2); - QByteArray data; - data.reserve(length); - auto lock = qt_unique_lock(prngMutex); - for (quint8 i = 0; i < length; ++i) - data.append(characters[distribution(*prng)]); - lock.unlock(); - return data; + // We'll use QByteArray::toBase64() to create a random-looking string from + // pure random data. In Base64 encoding, we get 6 bits of randomness per + // character, so at most 255 * 6 bits are needed in this function. + using Word = QRandomGenerator::result_type; + auto wordCountForLength = [](int len) constexpr { + constexpr int BitsPerWord = std::numeric_limits<Word>::digits; + int bitcount = len * 6; + return (bitcount + BitsPerWord - 1) / BitsPerWord; + }; + constexpr int RandomBufferLength = wordCountForLength(std::numeric_limits<quint8>::max()); + Word randomdata[RandomBufferLength]; + + qsizetype randomlen = wordCountForLength(length); + QRandomGenerator::system()->fillRange(randomdata, randomlen); + QByteArray ba = QByteArray::fromRawData(reinterpret_cast<char *>(randomdata), + randomlen * sizeof(quint32)) + .toBase64(QByteArray::Base64UrlEncoding); + ba.truncate(length); // toBase64 output length has fixed lengths: 6, 11, 16, 22, 27... + return ba; } QByteArray QAbstractOAuthPrivate::convertParameters(const QVariantMap ¶meters) diff --git a/src/oauth/qabstractoauth2.cpp b/src/oauth/qabstractoauth2.cpp index 1a34c46..e43f8eb 100644 --- a/src/oauth/qabstractoauth2.cpp +++ b/src/oauth/qabstractoauth2.cpp @@ -47,6 +47,11 @@ using namespace Qt::StringLiterals; \property QAbstractOAuth2::scope \brief This property holds the desired scope which defines the permissions requested by the client. + + The scope value is updated to the scope value granted by the + authorization server. In case of an empty scope response, the + \l {https://datatracker.ietf.org/doc/html/rfc6749#section-5.1} + {requested scope is assumed as granted and does not change}. */ /*! diff --git a/src/oauth/qoauth2authorizationcodeflow.cpp b/src/oauth/qoauth2authorizationcodeflow.cpp index 9eaef67..d979aae 100644 --- a/src/oauth/qoauth2authorizationcodeflow.cpp +++ b/src/oauth/qoauth2authorizationcodeflow.cpp @@ -55,6 +55,23 @@ QOAuth2AuthorizationCodeFlowPrivate::QOAuth2AuthorizationCodeFlowPrivate( responseType = QStringLiteral("code"); } +static QString toUrlFormEncoding(const QString &source) +{ + // RFC 6749 Appendix B + // https://datatracker.ietf.org/doc/html/rfc6749#appendix-B + // Replace spaces with plus, while percent-encoding the rest + QByteArray encoded = source.toUtf8().toPercentEncoding(" "); + encoded.replace(" ", "+"); + return QString::fromUtf8(encoded); +} + +static QString fromUrlFormEncoding(const QString &source) +{ + QByteArray decoded = source.toUtf8(); + decoded = QByteArray::fromPercentEncoding(decoded.replace("+"," ")); + return QString::fromUtf8(decoded); +} + void QOAuth2AuthorizationCodeFlowPrivate::_q_handleCallback(const QVariantMap &data) { Q_Q(QOAuth2AuthorizationCodeFlow); @@ -69,7 +86,8 @@ void QOAuth2AuthorizationCodeFlowPrivate::_q_handleCallback(const QVariantMap &d const QString error = data.value(Key::error).toString(); const QString code = data.value(Key::code).toString(); - const QString receivedState = data.value(Key::state).toString(); + const QString receivedState = fromUrlFormEncoding(data.value(Key::state).toString()); + if (error.size()) { const QString uri = data.value(Key::errorUri).toString(); const QString description = data.value(Key::errorDescription).toString(); @@ -118,13 +136,21 @@ void QOAuth2AuthorizationCodeFlowPrivate::_q_accessTokenRequestFinished(const QV expiresIn = -1; if (values.value(Key::refreshToken).isValid()) q->setRefreshToken(values.value(Key::refreshToken).toString()); - scope = values.value(Key::scope).toString(); + if (accessToken.isEmpty()) { qCWarning(loggingCategory, "Access token not received"); return; } q->setToken(accessToken); + // RFC 6749 section 5.1 https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 + // If the requested scope and granted scopes differ, server is REQUIRED to return + // the scope. If OTOH the scopes match, the server MAY omit the scope in the response, + // in which case we assume that the granted scope matches the requested scope. + const QString scope = values.value(Key::scope).toString(); + if (!scope.isEmpty()) + q->setScope(scope); + const QDateTime currentDateTime = QDateTime::currentDateTime(); if (expiresIn > 0 && currentDateTime.secsTo(expiresAt) != expiresIn) { expiresAt = currentDateTime.addSecs(expiresIn); @@ -350,7 +376,7 @@ QUrl QOAuth2AuthorizationCodeFlow::buildAuthenticateUrl(const QMultiMap<QString, p.insert(Key::clientIdentifier, d->clientIdentifier); p.insert(Key::redirectUri, callback()); p.insert(Key::scope, d->scope); - p.insert(Key::state, state); + p.insert(Key::state, toUrlFormEncoding(state)); if (d->modifyParametersFunction) d->modifyParametersFunction(Stage::RequestingAuthorization, &p); url.setQuery(d->createQuery(p)); diff --git a/src/oauth/qoauthhttpserverreplyhandler.cpp b/src/oauth/qoauthhttpserverreplyhandler.cpp index 5dbcf4b..0e2c642 100644 --- a/src/oauth/qoauthhttpserverreplyhandler.cpp +++ b/src/oauth/qoauthhttpserverreplyhandler.cpp @@ -39,6 +39,13 @@ QOAuthHttpServerReplyHandlerPrivate::~QOAuthHttpServerReplyHandlerPrivate() httpServer.close(); } +QString QOAuthHttpServerReplyHandlerPrivate::callback() const +{ + const QUrl url(QString::fromLatin1("http://127.0.0.1:%1/%2") + .arg(callbackPort).arg(path)); + return url.toString(QUrl::EncodeDelimiters); +} + void QOAuthHttpServerReplyHandlerPrivate::_q_clientConnected() { QTcpSocket *socket = httpServer.nextPendingConnection(); @@ -251,11 +258,7 @@ QOAuthHttpServerReplyHandler::~QOAuthHttpServerReplyHandler() QString QOAuthHttpServerReplyHandler::callback() const { Q_D(const QOAuthHttpServerReplyHandler); - - Q_ASSERT(d->httpServer.isListening()); - const QUrl url(QString::fromLatin1("http://127.0.0.1:%1/%2") - .arg(d->httpServer.serverPort()).arg(d->path)); - return url.toString(QUrl::EncodeDelimiters); + return d->callback(); } QString QOAuthHttpServerReplyHandler::callbackPath() const @@ -296,7 +299,13 @@ quint16 QOAuthHttpServerReplyHandler::port() const bool QOAuthHttpServerReplyHandler::listen(const QHostAddress &address, quint16 port) { Q_D(QOAuthHttpServerReplyHandler); - return d->httpServer.listen(address, port); + const bool success = d->httpServer.listen(address, port); + + if (success) { + // Callback ('redirect_uri') value may be needed after this handler is closed + d->callbackPort = d->httpServer.serverPort(); + } + return success; } void QOAuthHttpServerReplyHandler::close() diff --git a/src/oauth/qoauthhttpserverreplyhandler_p.h b/src/oauth/qoauthhttpserverreplyhandler_p.h index c7c9323..c20866c 100644 --- a/src/oauth/qoauthhttpserverreplyhandler_p.h +++ b/src/oauth/qoauthhttpserverreplyhandler_p.h @@ -34,10 +34,13 @@ public: explicit QOAuthHttpServerReplyHandlerPrivate(QOAuthHttpServerReplyHandler *p); ~QOAuthHttpServerReplyHandlerPrivate(); + QString callback() const; + QTcpServer httpServer; QString text; QHostAddress listenAddress = QHostAddress::LocalHost; QString path; + quint16 callbackPort = 0; private: void _q_clientConnected(); diff --git a/tests/auto/abstractoauth/tst_abstractoauth.cpp b/tests/auto/abstractoauth/tst_abstractoauth.cpp index 36d04f8..a813574 100644 --- a/tests/auto/abstractoauth/tst_abstractoauth.cpp +++ b/tests/auto/abstractoauth/tst_abstractoauth.cpp @@ -42,6 +42,8 @@ private: private Q_SLOTS: void authorizationUrlSignal(); + void generateRandomString_data(); + void generateRandomString(); }; void tst_AbstractOAuth::authorizationUrlSignal() @@ -59,5 +61,52 @@ void tst_AbstractOAuth::authorizationUrlSignal() QVERIFY(emitted); } +void tst_AbstractOAuth::generateRandomString_data() +{ + QTest::addColumn<int>("length"); + for (int i = 0; i <= 255; ++i) + QTest::addRow("%d", i) << i; +} + +// copied from https://xkcd.com/221/ +int getRandomNumber() +{ + return 4; // chosen by fair dice roll. + // guaranteed to be random. +} + +void tst_AbstractOAuth::generateRandomString() +{ + struct Unprotected : public QAbstractOAuth { + using QAbstractOAuth::generateRandomString; + }; + + QFETCH(int, length); + QByteArray random1 = Unprotected::generateRandomString(length); + QCOMPARE(random1.size(), length); + + // Check that it is truly random by repeating and checking that it is + // different. We don't try it for 1 and 2 characters because the chance of + // random coincidence is too high: 1 in 2^(6*n), so 1 in 64 and 1 in 4096 + // respectively. For 3 characters, that decreases to 1 in 262,144. + if (length <= 2) + return; + + QByteArray random2 = Unprotected::generateRandomString(length); + QCOMPARE_NE(random2, random1); + + // Generate a Base64 string using getRandomNumber() random bytes. Base64 + // encodes 6 bits per byte, so a 255-character string has 1530 bits of + // data. + char buf[192] = {}; + int rawlen = (length * 6 + 7) / 8; + for (int i = 0; i < rawlen; ++i) + buf[i] = getRandomNumber(); + QByteArray random3 = QByteArray(buf, rawlen).toBase64(QByteArray::Base64UrlEncoding); + Q_ASSERT(random3.size() >= length); + random3.truncate(length); + QCOMPARE_NE(random3, random1); +} + QTEST_MAIN(tst_AbstractOAuth) #include "tst_abstractoauth.moc" diff --git a/tests/auto/oauth2/tst_oauth2.cpp b/tests/auto/oauth2/tst_oauth2.cpp index 4aa4f66..e7fbf98 100644 --- a/tests/auto/oauth2/tst_oauth2.cpp +++ b/tests/auto/oauth2/tst_oauth2.cpp @@ -13,16 +13,21 @@ #include "webserver.h" #include "tlswebserver.h" +using namespace Qt::StringLiterals; + class tst_OAuth2 : public QObject { Q_OBJECT private Q_SLOTS: void initTestCase(); + void state(); void getToken(); void refreshToken(); void getAndRefreshToken(); void prepareRequest(); + void scope_data(); + void scope(); #ifndef QT_NO_SSL void setSslConfig(); void tlsAuthentication(); @@ -63,6 +68,52 @@ void tst_OAuth2::initTestCase() testDataDir += QLatin1String("/"); } +void tst_OAuth2::state() +{ + QOAuth2AuthorizationCodeFlow oauth2; + oauth2.setAuthorizationUrl(QUrl{"authorizationUrl"_L1}); + oauth2.setAccessTokenUrl(QUrl{"accessTokenUrl"_L1}); + ReplyHandler replyHandler; + oauth2.setReplyHandler(&replyHandler); + QSignalSpy statePropertySpy(&oauth2, &QAbstractOAuth2::stateChanged); + + QString stateParameter; + oauth2.setModifyParametersFunction( + [&] (QAbstractOAuth::Stage, QMultiMap<QString, QVariant> *parameters) { + stateParameter = parameters->value(u"state"_s).toString(); + }); + + oauth2.grant(); + QVERIFY(!stateParameter.isEmpty()); // internally generated initial state used + QCOMPARE(stateParameter, oauth2.state()); + + // Test setting the 'state' property + const QString simpleState = u"a_state"_s; + oauth2.setState(simpleState); + QCOMPARE(oauth2.state(), simpleState); + QCOMPARE(statePropertySpy.size(), 1); + QCOMPARE(statePropertySpy.at(0).at(0), simpleState); + oauth2.grant(); + QCOMPARE(stateParameter, simpleState); + + // Test 'state' that requires encoding/decoding. + // The 'state' value contains all allowed characters as defined by + // https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.5 + // state = 1*VSCHAR + // Where + // VSCHAR = %x20-7E + const QString stateRequiringEncoding = u"! \"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"_s; + const QString stateAsEncoded = u"%21+%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F0123456789%3A%3B%3C%3D%3E%3F%40ABCDEFGHIJKLMNOPQRSTUVWXYZ%5B%5C%5D%5E_%60abcdefghijklmnopqrstuvwxyz%7B%7C%7D~"_s; + oauth2.setState(stateRequiringEncoding); + QCOMPARE(oauth2.state(), stateRequiringEncoding); + oauth2.grant(); + QCOMPARE(stateParameter, stateAsEncoded); + // Conclude authorization stage, and check that the 'state' which we returned as encoded + // matches the original decoded state (ie. the status changes to TemporaryCredentialsReceived) + replyHandler.emitCallbackReceived({{"code", "acode"}, {"state", stateAsEncoded}}); + QTRY_COMPARE(oauth2.status(), QAbstractOAuth::Status::TemporaryCredentialsReceived); +} + void tst_OAuth2::getToken() { WebServer webServer([](const WebServer::HttpRequest &request, QTcpSocket *socket) { @@ -173,6 +224,75 @@ void tst_OAuth2::prepareRequest() QCOMPARE(request.rawHeader("Authorization"), QByteArray("Bearer access_token")); } +void tst_OAuth2::scope_data() +{ + static const auto requestedScope = u"requested"_s; + QTest::addColumn<QString>("scope"); + QTest::addColumn<QString>("granted_scope"); + QTest::addColumn<QString>("expected_scope"); + + QTest::addRow("scope_returned") << requestedScope << requestedScope << requestedScope; + QTest::addRow("differing_scope_returned") << requestedScope << u"granted"_s << u"granted"_s; + QTest::addRow("empty_scope_returned") << requestedScope << u""_s << requestedScope; +} + +void tst_OAuth2::scope() +{ + QFETCH(QString, scope); + QFETCH(QString, granted_scope); + QFETCH(QString, expected_scope); + + QOAuth2AuthorizationCodeFlow oauth2; + QVERIFY(oauth2.scope().isEmpty()); + + // Set the requested scope and verify it changes + QSignalSpy scopeSpy(&oauth2, &QAbstractOAuth2::scopeChanged); + oauth2.setScope(scope); + QCOMPARE(scopeSpy.size(), 1); + QCOMPARE(oauth2.scope(), scope); + QCOMPARE(scopeSpy.at(0).at(0).toString(), scope); + + // Verify that empty authorization server 'scope' response doesn't overwrite the + // requested scope, whereas a returned scope value does + WebServer webServer([granted_scope](const WebServer::HttpRequest &request, QTcpSocket *socket) { + if (request.url.path() == "/accessTokenUrl"_L1) { + QString accessTokenResponseParams; + accessTokenResponseParams += u"access_token=token&token_type=bearer"_s; + if (!granted_scope.isEmpty()) + accessTokenResponseParams += u"&scope="_s + granted_scope; + const QByteArray replyMessage { + "HTTP/1.0 200 OK\r\n" + "Content-Type: application/x-www-form-urlencoded; charset=\"utf-8\"\r\n" + "Content-Length: " + + QByteArray::number(accessTokenResponseParams.size()) + "\r\n\r\n" + + accessTokenResponseParams.toUtf8() + }; + socket->write(replyMessage); + } + }); + oauth2.setAuthorizationUrl(webServer.url("authorizationUrl"_L1)); + oauth2.setAccessTokenUrl(webServer.url("accessTokenUrl"_L1)); + oauth2.setState("a_state"_L1); + ReplyHandler replyHandler; + oauth2.setReplyHandler(&replyHandler); + connect(&oauth2, &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, + this, [&](const QUrl &) { + replyHandler.emitCallbackReceived(QVariantMap { + { "code"_L1, "a_code"_L1 }, { "state"_L1, "a_state"_L1 }, + }); + }); + oauth2.grant(); + + QTRY_COMPARE(oauth2.status(), QAbstractOAuth::Status::Granted); + QCOMPARE(oauth2.scope(), expected_scope); + if (!granted_scope.isEmpty() && (granted_scope != scope)) { + QCOMPARE(scopeSpy.size(), 2); + QCOMPARE(scopeSpy.at(1).at(0).toString(), expected_scope); + } else { + QCOMPARE(scopeSpy.size(), 1); + } +} + #ifndef QT_NO_SSL static QSslConfiguration createSslConfiguration(QString keyFileName, QString certificateFileName) { diff --git a/tests/auto/oauthhttpserverreplyhandler/tst_oauthhttpserverreplyhandler.cpp b/tests/auto/oauthhttpserverreplyhandler/tst_oauthhttpserverreplyhandler.cpp index 9a66efe..66e3a81 100644 --- a/tests/auto/oauthhttpserverreplyhandler/tst_oauthhttpserverreplyhandler.cpp +++ b/tests/auto/oauthhttpserverreplyhandler/tst_oauthhttpserverreplyhandler.cpp @@ -9,12 +9,15 @@ typedef QSharedPointer<QNetworkReply> QNetworkReplyPtr; +using namespace Qt::StringLiterals; + class tst_QOAuthHttpServerReplyHandler : public QObject { Q_OBJECT private Q_SLOTS: void callback(); + void callbackCaching(); }; void tst_QOAuthHttpServerReplyHandler::callback() @@ -46,5 +49,31 @@ void tst_QOAuthHttpServerReplyHandler::callback() QCOMPARE(count, query.queryItems().size()); } +void tst_QOAuthHttpServerReplyHandler::callbackCaching() +{ + QOAuthHttpServerReplyHandler replyHandler; + constexpr auto callbackPath = "/foo"_L1; + constexpr auto callbackHost = "127.0.0.1"_L1; + + QVERIFY(replyHandler.isListening()); + replyHandler.setCallbackPath(callbackPath); + QUrl callback = replyHandler.callback(); + QCOMPARE(callback.path(), callbackPath); + QCOMPARE(callback.host(), callbackHost); + + replyHandler.close(); + QVERIFY(!replyHandler.isListening()); + callback = replyHandler.callback(); + // Should remain after close + QCOMPARE(callback.path(), callbackPath); + QCOMPARE(callback.host(), callbackHost); + + replyHandler.listen(); + QVERIFY(replyHandler.isListening()); + callback = replyHandler.callback(); + QCOMPARE(callback.path(), callbackPath); + QCOMPARE(callback.host(), callbackHost); +} + QTEST_MAIN(tst_QOAuthHttpServerReplyHandler) #include "tst_oauthhttpserverreplyhandler.moc" |
