summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.cmake.conf2
-rw-r--r--dependencies.yaml2
-rw-r--r--src/oauth/qabstractoauth.cpp33
-rw-r--r--src/oauth/qabstractoauth2.cpp5
-rw-r--r--src/oauth/qoauth2authorizationcodeflow.cpp32
-rw-r--r--src/oauth/qoauthhttpserverreplyhandler.cpp21
-rw-r--r--src/oauth/qoauthhttpserverreplyhandler_p.h3
-rw-r--r--tests/auto/abstractoauth/tst_abstractoauth.cpp49
-rw-r--r--tests/auto/oauth2/tst_oauth2.cpp120
-rw-r--r--tests/auto/oauthhttpserverreplyhandler/tst_oauthhttpserverreplyhandler.cpp29
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 &parameters)
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"