Revision control

++ // Copyright (C) 2024 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
++
++ #include <QtTest/qtestcase.h>
++ #include <QtTest>
++
++ #include "oauthtestutils.h"
++
++ #include "private/qoauth2deviceauthorizationflow_p.h"
++
++ #include <QtNetworkAuth/qoauth2deviceauthorizationflow.h>
++
++ #ifndef QT_NO_SSL
++ #include <QtNetwork/qsslkey.h>
++ #endif
++
++
++ #include <QtCore/qoperatingsystemversion.h>
++ #include <QtCore/qsystemdetection.h>
++
++ #include <QtCore/qcryptographichash.h>
++
++ using namespace Qt::StringLiterals;
++ using namespace std::chrono_literals;
++ using Error = QAbstractOAuth::Error;
++ using Status = QAbstractOAuth::Status;
++ using Stage = QAbstractOAuth::Stage;
++
++ class tst_OAuth2DeviceFlow : public QObject
++ {
++ Q_OBJECT
++
++ private Q_SLOTS:
++ void initTestCase();
++ void getAndRefreshToken();
++ void clientError();
++ void authorizationErrors();
++ void tokenRequestErrors();
++ void idToken();
++ void nonce();
++ void requestedScopeTokens_data();
++ void requestedScopeTokens();
++ void grantedScopeTokens_data();
++ void grantedScopeTokens();
++ void refreshLeadTime_data();
++ void refreshLeadTime();
++ void modifyTokenRequests();
++ void userCodeExpiration();
++ void startStopTokenPolling();
++ void destruction_data();
++ void destruction();
++ void changeNetworkAccessManager();
++ #ifndef QT_NO_SSL
++ void tlsAuthentication();
++ #endif
++
++ private:
++ QString testDataDir;
++ };
++
++ namespace Responses {
++ // Basic successful authorization response. Note that interval is 50,
++ // which maps to 50ms if DeviceFlow useAutoTestDurations is true, and 50s
++ // otherwise.
++ static const auto authorizationSuccess = R"(
++ {
++ "device_code": "a-device-code",
++ "user_code": "a-user-code",
++ "verification_uri": "a-verification-uri",
++ "verification_uri_complete": "a-verification-uri-complete",
++ "expires_in": 1800,
++ "interval": 50
++ })"_ba;
++
++ static const auto tokenSuccess = R"(
++ {
++ "access_token": "an-access-token",
++ "refresh_token": "a-refresh-token",
++ "token_type": "bearer",
++ "expires_in": 3600
++ })"_ba;
++
++ static const auto tokenAuthorizationPending = R"(
++ {
++ "error": "authorization_pending",
++ "error_description": "User hasnt authorized yet",
++ "error_uri": "an-error-uri"
++ })"_ba;
++
++ static const auto tokenExpired = R"(
++ {
++ "error": "expired_token",
++ "error_description": "code expired",
++ "error_uri": "an-error-uri"
++ })"_ba;
++
++ static const auto OK_200 = "200 OK"_ba;
++ static const auto BR_400 = "400 Bad Request"_ba;
++
++ static QByteArray authorizationResponseWithTimes(int interval, int expiration) {
++ return R"(
++ {
++ "device_code": "a-device-code",
++ "user_code": "a-user-code",
++ "verification_uri": "a-verification-uri",
++ "verification_uri_complete": "a-verification-uri-complete",
++ "interval": )" + QByteArray::number(interval) + "," +
++ R"("expires_in": )" + QByteArray::number(expiration) +
++ "}";
++ };
++
++ ServerResponses defaults()
++ {
++ ServerResponses responses;
++ responses.authBody = authorizationSuccess;
++ responses.authHttpStatus = OK_200;
++ responses.tokenBody = tokenSuccess;
++ responses.tokenHttpStatus = OK_200;
++ return responses;
++ }
++ };
++
++ // This class is used to access flow class's private class, in order to make
++ // the class use milliseconds instead of seconds for expirations and intervals,
++ // making these autotest finish in a more pragmatic timeframe
++ class DeviceFlow : public QOAuth2DeviceAuthorizationFlow
++ {
++ public:
++ QOAuth2DeviceAuthorizationFlowPrivate *flowPrivate()
++ {
++ return static_cast<QOAuth2DeviceAuthorizationFlowPrivate*>(d_ptr.get());
++ }
++ };
++
++ static void expectWarning(const QString &warningText)
++ {
++ const QRegularExpression warning(warningText);
++ QTest::ignoreMessage(QtWarningMsg, warning);
++ };
++
++ void tst_OAuth2DeviceFlow::initTestCase()
++ {
++ // QLoggingCategory::setFilterRules(QStringLiteral("qt.networkauth* = true"));
++ #ifdef BUILTIN_TESTDATA
++ testDataDir = QStringLiteral(":/shared/");
++ #else
++ testDataDir = QFileInfo(QFINDTESTDATA("../shared/certs")).absolutePath();
++ if (testDataDir.isEmpty())
++ testDataDir = QCoreApplication::applicationDirPath();
++ if (!testDataDir.endsWith(QLatin1String("/")))
++ testDataDir += QLatin1String("/");
++ #endif
++ }
++
++ class RequestModifier : public QObject
++ {
++ Q_OBJECT
++ public:
++ RequestModifier(QObject *parent = nullptr) : QObject(parent) {}
++
++ void handleRequestModification(QNetworkRequest &request, Stage stage)
++ {
++ stagesReceivedByModifier.append(stage);
++ auto headers = request.headers();
++ headers.append("test-header-name"_ba, valueToSet);
++ request.setHeaders(headers);
++ }
++ QList<Stage> stagesReceivedByModifier;
++ QByteArray valueToSet;
++ };
++
++ #define TEST_MODIFY_REQUEST_WITH_MODIFIER(STAGES_RECEIVED, VALUE_SET, VALUE_PREFIX) \
++ do { \
++ server->receivedAuthorizationRequests.clear(); \
++ server->receivedTokenRequests.clear(); \
++ STAGES_RECEIVED.clear(); \
++ VALUE_SET = QByteArray(VALUE_PREFIX) + "_authorization_and_access_token"; \
++ oauth2.grant(); \
++ QTRY_COMPARE(STAGES_RECEIVED.size(), 2); \
++ QCOMPARE(STAGES_RECEIVED.at(0), Stage::RequestingAuthorization); \
++ QCOMPARE(STAGES_RECEIVED.at(1), Stage::RequestingAccessToken); \
++ QTRY_COMPARE(server->receivedAuthorizationRequests.size(), 1); \
++ QCOMPARE(server->receivedAuthorizationRequests.at(0).headers.value("test-header-name"_ba), VALUE_SET); \
++ QTRY_COMPARE(server->receivedTokenRequests.size(), 1); \
++ QCOMPARE(server->receivedTokenRequests.at(0).headers.value("test-header-name"_ba), VALUE_SET); \
++ QTRY_COMPARE(oauth2.status(), Status::Granted); \
++ /* Refresh token request */ \
++ VALUE_SET = QByteArray(VALUE_PREFIX) + "_refresh_token"; \
++ server->receivedTokenRequests.clear(); \
++ STAGES_RECEIVED.clear(); \
++ requestFailedSpy.clear(); \
++ oauth2.refreshTokens(); \
++ QVERIFY(requestFailedSpy.isEmpty()); \
++ QCOMPARE(oauth2.status(), Status::RefreshingToken); \
++ QTRY_COMPARE(STAGES_RECEIVED.size(), 1); \
++ QCOMPARE(STAGES_RECEIVED.at(0), Stage::RefreshingAccessToken); \
++ QTRY_COMPARE(server->receivedTokenRequests.size(), 1); \
++ QCOMPARE(server->receivedTokenRequests.at(0).headers.value("test-header-name"_ba), VALUE_SET); \
++ QTRY_COMPARE(oauth2.status(), Status::Granted); \
++ oauth2.clearNetworkRequestModifier(); \
++ } while (false) \
++
++ #define TEST_MODIFY_REQUEST_WITHOUT_MODIFIER(VALUE_SET) \
++ do { \
++ server->receivedAuthorizationRequests.clear(); \
++ server->receivedTokenRequests.clear(); \
++ VALUE_SET = "must_not_be_set"_ba; \
++ oauth2.grant(); \
++ QTRY_COMPARE(oauth2.status(), Status::Granted); \
++ QTRY_COMPARE(server->receivedAuthorizationRequests.size(), 1); \
++ QVERIFY(server->receivedAuthorizationRequests.at(0).headers.value("test-header-name"_ba).isEmpty()); \
++ QTRY_COMPARE(server->receivedTokenRequests.size(), 1); \
++ QVERIFY(server->receivedTokenRequests.at(0).headers.value("test-header-name"_ba).isEmpty()); \
++ server->receivedTokenRequests.clear(); \
++ requestFailedSpy.clear(); \
++ oauth2.refreshTokens(); \
++ QVERIFY(requestFailedSpy.isEmpty()); \
++ QCOMPARE(oauth2.status(), Status::RefreshingToken); \
++ QTRY_COMPARE(oauth2.status(), Status::Granted); \
++ QTRY_COMPARE(server->receivedTokenRequests.size(), 1); \
++ QVERIFY(server->receivedTokenRequests.at(0).headers.value("test-header-name"_ba).isEmpty()); \
++ oauth2.clearNetworkRequestModifier(); \
++ } while (false) \
++
++ void tst_OAuth2DeviceFlow::modifyTokenRequests()
++ {
++ std::unique_ptr<RequestModifier> context(new RequestModifier);
++ QRegularExpression nullContextWarning(u".*Context object must not be null, ignoring"_s);
++ QRegularExpression wrongThreadWarning(u".*Context object must reside in the same thread"_s);
++ auto valueToSet = ""_ba;
++
++ auto server = createAuthorizationServer<WebServer>(Responses::defaults());
++
++ DeviceFlow oauth2;
++ oauth2.flowPrivate()->useAutoTestDurations = true;
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(server->tokenEndpoint());
++ oauth2.setRefreshToken(u"refresh_token"_s);
++
++ QSignalSpy requestFailedSpy(&oauth2, &QOAuth2DeviceAuthorizationFlow::requestFailed);
++
++ QList<Stage> stagesReceivedByModifier;
++ auto modifierLambda = [&](QNetworkRequest &request, Stage stage) {
++ stagesReceivedByModifier.append(stage);
++ auto headers = request.headers();
++ headers.append("test-header-name"_ba, valueToSet);
++ request.setHeaders(headers);
++ };
++ std::function<void(QNetworkRequest &, Stage)> modifierFunc = modifierLambda;
++
++ // Lambda with a context object
++ oauth2.setNetworkRequestModifier(context.get(), modifierLambda);
++ TEST_MODIFY_REQUEST_WITH_MODIFIER(stagesReceivedByModifier,
++ valueToSet, "lambda_with_context");
++
++ // Test that the modifier will be cleared
++ oauth2.clearNetworkRequestModifier();
++ TEST_MODIFY_REQUEST_WITHOUT_MODIFIER(valueToSet);
++
++ // Lambda without a context object
++ QTest::ignoreMessage(QtWarningMsg, nullContextWarning);
++ oauth2.setNetworkRequestModifier(nullptr, modifierLambda);
++ TEST_MODIFY_REQUEST_WITHOUT_MODIFIER(valueToSet);
++
++ // std::function with a context object
++ oauth2.setNetworkRequestModifier(context.get(), modifierFunc);
++ TEST_MODIFY_REQUEST_WITH_MODIFIER(stagesReceivedByModifier, valueToSet, "func_with_context");
++
++ // PMF with context object
++ oauth2.setNetworkRequestModifier(context.get(), &RequestModifier::handleRequestModification);
++ TEST_MODIFY_REQUEST_WITH_MODIFIER(context->stagesReceivedByModifier,
++ context->valueToSet, "pmf_with_context");
++
++ // PMF without context object
++ QTest::ignoreMessage(QtWarningMsg, nullContextWarning);
++ oauth2.setNetworkRequestModifier(nullptr, &RequestModifier::handleRequestModification);
++ TEST_MODIFY_REQUEST_WITHOUT_MODIFIER(context->valueToSet);
++
++ // Destroy context object => no callback (or crash)
++ oauth2.setNetworkRequestModifier(context.get(), modifierLambda);
++ context.reset(nullptr);
++ TEST_MODIFY_REQUEST_WITHOUT_MODIFIER(valueToSet);
++
++ // Context object in wrong thread
++ QThread thread;
++ QObject objectInWrongThread;
++ // Initially context object is in correct thread
++ oauth2.setNetworkRequestModifier(&objectInWrongThread, modifierLambda);
++ // Move to wrong thread, verify we get warnings when it's time to call the callback
++ objectInWrongThread.moveToThread(&thread);
++ QTest::ignoreMessage(QtWarningMsg, wrongThreadWarning);
++ oauth2.grant();
++ QTRY_COMPARE(oauth2.status(), Status::Granted);
++ // Now the context object is in wrong thread when attempting to set the modifier
++ oauth2.clearNetworkRequestModifier();
++ QTest::ignoreMessage(QtWarningMsg, wrongThreadWarning);
++ oauth2.setNetworkRequestModifier(&objectInWrongThread, modifierLambda);
++ TEST_MODIFY_REQUEST_WITHOUT_MODIFIER(valueToSet);
++ }
++
++ void tst_OAuth2DeviceFlow::userCodeExpiration()
++ {
++ auto responses = Responses::defaults();
++ responses.tokenBody = Responses::tokenAuthorizationPending;
++ responses.tokenHttpStatus = Responses::BR_400;
++ auto server = createAuthorizationServer<WebServer>(responses);
++
++ QOAuth2DeviceAuthorizationFlow oauth2;
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(server->tokenEndpoint());
++
++ QSignalSpy requestFailedSpy(&oauth2, &QAbstractOAuth::requestFailed);
++ QSignalSpy statusSpy(&oauth2, &QAbstractOAuth::statusChanged);
++ QSignalSpy grantedSpy(&oauth2, &QAbstractOAuth::granted);
++ QSignalSpy codeExpirationSpy(&oauth2,
++ &QOAuth2DeviceAuthorizationFlow::userCodeExpirationAtChanged);
++
++ const auto clearTestData = [&](){
++ server->receivedAuthorizationRequests.clear();
++ server->receivedTokenRequests.clear();
++ requestFailedSpy.clear();
++ codeExpirationSpy.clear();
++ statusSpy.clear();
++ grantedSpy.clear();
++ };
++
++ // Initial expiration
++ QCOMPARE(oauth2.userCodeExpirationAt(), QDateTime());
++
++ // Code would expire before first poll request
++ server->responses.authBody = Responses::authorizationResponseWithTimes(100, 50);
++ expectWarning("code expired");
++ oauth2.grant();
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ExpiredError);
++ QCOMPARE(codeExpirationSpy.size(), 1);
++ // The expiration is expressed as seconds from 'now'. To robustly compare the time
++ // that device flow class calculates vs. what this test case calculates, verify
++ // the expiration with two seconds tolerance
++ QVERIFY(
++ qAbs(oauth2.userCodeExpirationAt().secsTo(QDateTime::currentDateTime().addSecs(50))) <= 2);
++ QCOMPARE(codeExpirationSpy.at(0).at(0).toDateTime(), oauth2.userCodeExpirationAt());
++ QCOMPARE(server->receivedAuthorizationRequests.size(), 1);
++ QCOMPARE(server->receivedTokenRequests.size(), 0);
++ QCOMPARE(oauth2.status(), Status::NotAuthenticated);
++
++ // Code expires while polling
++ clearTestData();
++ server->responses.authBody = Responses::authorizationResponseWithTimes(1, 4);
++ expectWarning("code expired");
++ oauth2.grant();
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ExpiredError);
++ QCOMPARE(codeExpirationSpy.size(), 2);
++ QCOMPARE(codeExpirationSpy.at(0).at(0).toDateTime(), QDateTime()); // grant() resets properties
++ QCOMPARE(codeExpirationSpy.at(1).at(0).toDateTime(), oauth2.userCodeExpirationAt());
++ QCOMPARE(server->receivedAuthorizationRequests.size(), 1);
++ QVERIFY(server->receivedTokenRequests.size() > 1); // first at 1s, then at 2s, ...
++ QCOMPARE(oauth2.status(), Status::TemporaryCredentialsReceived);
++
++ // Code expiration is indicated by authorization server's token response
++ clearTestData();
++ server->responses.tokenBody = Responses::tokenExpired;
++ expectWarning("code expired");
++ oauth2.grant();
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ExpiredError);
++ }
++
++ void tst_OAuth2DeviceFlow::startStopTokenPolling()
++ {
++ auto responses = Responses::defaults();
++ responses.tokenHttpStatus = Responses::BR_400;
++ responses.tokenBody = Responses::tokenAuthorizationPending;
++ auto server = createAuthorizationServer<WebServer>(responses);
++
++ DeviceFlow oauth2;
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(server->tokenEndpoint());
++ oauth2.flowPrivate()->useAutoTestDurations = true;
++ QSignalSpy pollingSpy(&oauth2, &QOAuth2DeviceAuthorizationFlow::pollingChanged);
++ QSignalSpy requestFailedSpy(&oauth2, &QAbstractOAuth::requestFailed);
++
++ auto clearTestVariables = [&](){
++ pollingSpy.clear();
++ requestFailedSpy.clear();
++ server->receivedAuthorizationRequests.clear();
++ server->receivedTokenRequests.clear();
++ };
++
++ // Initial state
++ QVERIFY(!oauth2.isPolling());
++ oauth2.stopTokenPolling(); // Mustn't cause harm
++
++ // Flow doesn't yet have device code
++ expectWarning(u"missing device code for polling"_s);
++ QVERIFY(!oauth2.startTokenPolling());
++ QVERIFY(!oauth2.isPolling());
++ QVERIFY(pollingSpy.isEmpty());
++ QCOMPARE(requestFailedSpy.size(), 1);
++
++ // Successful start of flow, enters polling automatically
++ clearTestVariables();
++ oauth2.grant();
++ QTRY_COMPARE(pollingSpy.size(), 1);
++ QVERIFY(pollingSpy.at(0).at(0).toBool());
++ QCOMPARE(server->receivedAuthorizationRequests.size(), 1);
++ QVERIFY(oauth2.isPolling());
++ QCOMPARE(oauth2.status(), Status::TemporaryCredentialsReceived);
++ QVERIFY(requestFailedSpy.isEmpty());
++
++ // Stop polling
++ clearTestVariables();
++ oauth2.stopTokenPolling();
++ QTRY_COMPARE(pollingSpy.size(), 1);
++ QVERIFY(!pollingSpy.at(0).at(0).toBool());
++ QVERIFY(!oauth2.isPolling());
++ QCOMPARE(oauth2.status(), Status::TemporaryCredentialsReceived);
++ QVERIFY(requestFailedSpy.isEmpty());
++
++ // Resume polling manually
++ clearTestVariables();
++ QVERIFY(oauth2.startTokenPolling());
++ QTRY_COMPARE(pollingSpy.size(), 1);
++ QVERIFY(pollingSpy.at(0).at(0).toBool());
++ QTRY_COMPARE(server->receivedTokenRequests.size(), 1);
++ QCOMPARE(server->receivedAuthorizationRequests.size(), 0);
++ QVERIFY(oauth2.isPolling());
++ QCOMPARE(oauth2.status(), Status::TemporaryCredentialsReceived);
++ QVERIFY(requestFailedSpy.isEmpty());
++
++ // Polling already active, no impact
++ QVERIFY(oauth2.startTokenPolling());
++ QVERIFY(oauth2.isPolling());
++ QTRY_COMPARE(server->receivedTokenRequests.size(), 2);
++ QVERIFY(requestFailedSpy.isEmpty());
++
++ // Empty token endpoint URL during polling
++ clearTestVariables();
++ QVERIFY(oauth2.isPolling());
++ oauth2.setTokenUrl(QUrl());
++ expectWarning("token URL is empty");
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ClientError);
++ QVERIFY(!oauth2.isPolling());
++ QCOMPARE(oauth2.status(), Status::TemporaryCredentialsReceived);
++
++ // Attempt to start polling with empty token URL
++ clearTestVariables();
++ expectWarning("token URL is empty");
++ QVERIFY(!oauth2.startTokenPolling());
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ClientError);
++ QVERIFY(!oauth2.isPolling());
++ QCOMPARE(oauth2.status(), Status::TemporaryCredentialsReceived);
++ }
++
++ void tst_OAuth2DeviceFlow::getAndRefreshToken()
++ {
++ static constexpr auto clientId = "a-client-id"_L1;
++ static constexpr auto scope = "a-scope";
++ static constexpr auto accessToken = "an-access-token"_L1;
++ static constexpr auto clientSecret = "a-client-secret"_L1;
++ static constexpr auto deviceCode = "a-device-code"_L1;
++ static constexpr auto userCode = "a-user-code"_L1;
++ static constexpr auto refreshToken = "a-refresh-token"_L1;
++ static constexpr auto refreshToken2 = "a-refresh-token-2"_L1;
++ static constexpr auto verificationUrl = "a-verification-uri"_L1;
++ static constexpr auto completeVerificationUrl = "a-verification-uri-complete"_L1;
++
++ auto server = createAuthorizationServer<WebServer>(Responses::defaults());
++
++ DeviceFlow oauth2;
++ oauth2.flowPrivate()->useAutoTestDurations = true;
++
++ oauth2.setClientIdentifier(clientId);
++ oauth2.setClientIdentifierSharedKey(clientSecret);
++ oauth2.setRequestedScopeTokens({scope});
++ QSignalSpy grantedSpy(&oauth2, &QOAuth2DeviceAuthorizationFlow::granted);
++ QSignalSpy statusSpy(&oauth2, &QAbstractOAuth::statusChanged);
++ QSignalSpy userCodeSpy(&oauth2, &QOAuth2DeviceAuthorizationFlow::userCodeChanged);
++ QSignalSpy verificationUrlSpy(&oauth2, &QOAuth2DeviceAuthorizationFlow::verificationUrlChanged);
++ QSignalSpy completeVerificationUrlSpy(
++ &oauth2, &QOAuth2DeviceAuthorizationFlow::completeVerificationUrlChanged);
++ QSignalSpy authorizationUrlSpy(&oauth2,
++ &QOAuth2DeviceAuthorizationFlow::authorizationUrlChanged);
++ QSignalSpy tokenSpy(&oauth2, &QOAuth2DeviceAuthorizationFlow::tokenChanged);
++ QSignalSpy refreshTokenSpy(&oauth2, &QOAuth2DeviceAuthorizationFlow::refreshTokenChanged);
++ QSignalSpy requestFailedSpy(&oauth2, &QOAuth2DeviceAuthorizationFlow::requestFailed);
++
++ // Initial values
++ QCOMPARE(oauth2.status(), Status::NotAuthenticated);
++ QVERIFY(oauth2.userCode().isEmpty());
++ QVERIFY(oauth2.verificationUrl().isEmpty());
++ QVERIFY(oauth2.completeVerificationUrl().isEmpty());
++
++ // Set authorization url
++ const auto authorizationUrl = server->authorizationEndpoint();
++ oauth2.setAuthorizationUrl(authorizationUrl);
++ QCOMPARE(oauth2.authorizationUrl(), authorizationUrl);
++ QCOMPARE(authorizationUrlSpy.size(), 1);
++ QCOMPARE(authorizationUrlSpy.at(0).at(0).toUrl(), authorizationUrl);
++
++ // Get access token (and refresh token)
++ const auto tokenUrl = server->tokenEndpoint();
++ oauth2.setTokenUrl(tokenUrl);
++ oauth2.grant();
++ QTRY_COMPARE(grantedSpy.size(), 1);
++ QCOMPARE(oauth2.status(), Status::Granted);
++
++ // Verify requests that the authorization server received
++ QTRY_COMPARE(server->receivedAuthorizationRequests.size(), 1);
++ {
++ // Authorization request
++ QUrlQuery query(QUrl::fromPercentEncoding(server->receivedAuthorizationRequests.at(0).body));
++ QCOMPARE(query.queryItems().size(), 2);
++ QCOMPARE(query.queryItemValue("client_id"_L1), clientId);
++ QCOMPARE(query.queryItemValue("scope"_L1), scope);
++ }
++ QTRY_COMPARE(server->receivedTokenRequests.size(), 1);
++ {
++ // Access token poll request
++ QUrlQuery query(QUrl::fromPercentEncoding(server->receivedTokenRequests.at(0).body));
++ QCOMPARE(query.queryItems().size(), 4);
++ QCOMPARE(query.queryItemValue("grant_type"_L1),
++ "urn:ietf:params:oauth:grant-type:device_code"_L1);
++ QCOMPARE(query.queryItemValue("device_code"_L1), deviceCode);
++ QCOMPARE(query.queryItemValue("client_id"_L1), clientId);
++ QCOMPARE(query.queryItemValue("client_secret"_L1), clientSecret);
++ }
++
++ // Verify that appropriate properties changed
++ QCOMPARE(statusSpy.size(), 2);
++ QCOMPARE(statusSpy.at(0).at(0).value<Status>(), Status::TemporaryCredentialsReceived);
++ QCOMPARE(statusSpy.at(1).at(0).value<Status>(), Status::Granted);
++
++ QCOMPARE(oauth2.userCode(), userCode);
++ QCOMPARE(userCodeSpy.size(), 1);
++ QCOMPARE(userCodeSpy.at(0).at(0).toString(), userCode);
++
++ QCOMPARE(oauth2.verificationUrl(), QUrl(verificationUrl));
++ QCOMPARE(verificationUrlSpy.size(), 1);
++ QCOMPARE(verificationUrlSpy.at(0).at(0).toUrl(), QUrl(verificationUrl));
++
++ QCOMPARE(oauth2.completeVerificationUrl(), QUrl(completeVerificationUrl));
++ QCOMPARE(completeVerificationUrlSpy.size(), 1);
++ QCOMPARE(completeVerificationUrlSpy.at(0).at(0).toUrl(), QUrl(completeVerificationUrl));
++
++ QCOMPARE(oauth2.token(), accessToken);
++ QCOMPARE(tokenSpy.size(), 1);
++ QCOMPARE(tokenSpy.at(0).at(0).toString(), accessToken);
++
++ QCOMPARE(oauth2.refreshToken(), refreshToken);
++ QCOMPARE(refreshTokenSpy.size(), 1);
++ QCOMPARE(refreshTokenSpy.at(0).at(0).toString(), refreshToken);
++
++ // Manually set the refresh token, and then use it to refresh the access token
++ oauth2.setRefreshToken(refreshToken2);
++ QCOMPARE(refreshTokenSpy.size(), 2);
++ QCOMPARE(refreshTokenSpy.at(1).at(0).toString(), refreshToken2);
++
++ statusSpy.clear();
++ server->receivedTokenRequests.clear();
++ requestFailedSpy.clear();
++ oauth2.refreshTokens();
++ QVERIFY(requestFailedSpy.isEmpty());
++ QTRY_COMPARE(statusSpy.size(), 2);
++ QCOMPARE(server->receivedTokenRequests.size(), 1);
++ {
++ // Refresh request
++ QUrlQuery query(QUrl::fromPercentEncoding(server->receivedTokenRequests.at(0).body));
++ QCOMPARE(query.queryItems().size(), 4);
++ QCOMPARE(query.queryItemValue("grant_type"_L1), "refresh_token"_L1);
++ QCOMPARE(query.queryItemValue("refresh_token"_L1), refreshToken2);
++ QCOMPARE(query.queryItemValue("client_id"_L1), clientId);
++ QCOMPARE(query.queryItemValue("client_secret"_L1), clientSecret);
++ }
++ QCOMPARE(statusSpy.at(0).at(0).value<Status>(), Status::RefreshingToken);
++ QCOMPARE(statusSpy.at(1).at(0).value<Status>(), Status::Granted);
++ QCOMPARE(oauth2.status(), Status::Granted);
++ QCOMPARE(grantedSpy.size(), 2);
++ // The refresh token is also renewed
++ QCOMPARE(oauth2.refreshToken(), refreshToken);
++ QCOMPARE(oauth2.token(), accessToken);
++ }
++
++ void tst_OAuth2DeviceFlow::clientError()
++ {
++ DeviceFlow oauth2;
++ oauth2.flowPrivate()->useAutoTestDurations = true;
++ QSignalSpy requestFailedSpy(&oauth2, &QAbstractOAuth::requestFailed);
++
++ // Authorization URL missing with grant()
++ expectWarning("No authorization URL");
++ oauth2.grant();
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ClientError);
++
++ // Token URL missing with grant()
++ requestFailedSpy.clear();
++ oauth2.setAuthorizationUrl(QUrl("an-authorization-url"));
++ expectWarning("No token URL");
++ oauth2.grant();
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ClientError);
++
++ // Refresh token missing for refreshing
++ requestFailedSpy.clear();
++ expectWarning("empty refresh token");
++ oauth2.refreshTokens();
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ClientError);
++
++ // Token URL missing for refreshing
++ requestFailedSpy.clear();
++ oauth2.setRefreshToken("a-refresh-token"_L1);
++ expectWarning("No token URL");
++ oauth2.refreshTokens();
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ClientError);
++
++ auto server = createAuthorizationServer<WebServer>(Responses::defaults());
++
++ // Refresh while polling
++ requestFailedSpy.clear();
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(server->tokenEndpoint());
++ oauth2.grant();
++ QTRY_VERIFY(oauth2.isPolling());
++ expectWarning("polling in progress");
++ oauth2.refreshTokens();
++ QCOMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ClientError);
++
++ // Clear tokenUrl while polling
++ requestFailedSpy.clear();
++ QVERIFY(oauth2.isPolling());
++ expectWarning("token URL is empty");
++ oauth2.setTokenUrl({});
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ClientError);
++
++ // Refresh while refreshing, no client error
++ requestFailedSpy.clear();
++ oauth2.stopTokenPolling();
++ oauth2.setTokenUrl(server->tokenEndpoint());
++ oauth2.refreshTokens();
++ oauth2.refreshTokens();
++ oauth2.refreshTokens();
++ QVERIFY(requestFailedSpy.isEmpty());
++ }
++
++ QT_WARNING_PUSH QT_WARNING_DISABLE_DEPRECATED
++ void tst_OAuth2DeviceFlow::authorizationErrors()
++ {
++ auto server = createAuthorizationServer<WebServer>(Responses::defaults());
++
++ QOAuth2DeviceAuthorizationFlow oauth2;
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(QUrl("not-needed"_L1));
++
++ QSignalSpy statusSpy(&oauth2, &QAbstractOAuth::statusChanged);
++ QSignalSpy requestFailedSpy(&oauth2, &QAbstractOAuth::requestFailed);
++ #if QT_DEPRECATED_SINCE(6, 13)
++ QSignalSpy errorSpy(&oauth2, &QAbstractOAuth2::error);
++ #endif
++ QSignalSpy serverReportedErrorOccurredSpy(&oauth2,
++ &QAbstractOAuth2::serverReportedErrorOccurred);
++ const auto clearSpies = [&](){
++ requestFailedSpy.clear();
++ serverReportedErrorOccurredSpy.clear();
++ #if QT_DEPRECATED_SINCE(6, 13)
++ errorSpy.clear();
++ #endif
++ statusSpy.clear();
++ };
++
++ // Error response from the authorization server (RFC 6749 section 5.2)
++ server->responses.authBody = R"(
++ {
++ "error": "an-error",
++ "error_description": "an-error-description",
++ "error_uri": "an-error-uri"
++ }
++ )";
++ server->responses.authHttpStatus = Responses::BR_400;
++
++ oauth2.grant();
++
++ expectWarning("Authorization stage:.*");
++ #if QT_DEPRECATED_SINCE(6, 13)
++ QTRY_COMPARE(errorSpy.size(), 1);
++ #endif
++ QTRY_COMPARE(serverReportedErrorOccurredSpy.size(), 1);
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ #if QT_DEPRECATED_SINCE(6, 13)
++ QCOMPARE(errorSpy.at(0).at(0).toString(), "an-error"_L1);
++ QCOMPARE(errorSpy.at(0).at(1).toString(), "an-error-description"_L1);
++ QCOMPARE(errorSpy.at(0).at(2).toString(), "an-error-uri"_L1);
++ #endif
++ QCOMPARE(serverReportedErrorOccurredSpy.at(0).at(0).toString(), "an-error"_L1);
++ QCOMPARE(serverReportedErrorOccurredSpy.at(0).at(1).toString(), "an-error-description"_L1);
++ QCOMPARE(serverReportedErrorOccurredSpy.at(0).at(2).toString(), "an-error-uri"_L1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ServerError);
++ QVERIFY(statusSpy.isEmpty());
++ QCOMPARE(oauth2.status(), Status::NotAuthenticated);
++
++ const auto checkAuthorizationError = [&]()
++ {
++ clearSpies();
++ oauth2.grant();
++ expectWarning("Authorization stage:.*");
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::OAuthTokenNotFoundError);
++ QVERIFY(statusSpy.isEmpty());
++ QCOMPARE(oauth2.status(), Status::NotAuthenticated);
++ // Other error signals should not have been emitted
++ #if QT_DEPRECATED_SINCE(6, 13)
++ QCOMPARE(errorSpy.size(), 0);
++ #endif
++ QCOMPARE(serverReportedErrorOccurredSpy.size(), 0);
++ QVERIFY(statusSpy.isEmpty());
++ };
++
++ // Missing user code value
++ server->responses.authBody = R"(
++ {
++ "user_code": "",
++ "device_code": "a-device-code",
++ "verification_uri": "a-verification-uri",
++ "expires_in": 1800
++ })";
++ checkAuthorizationError();
++
++ // Missing user code field
++ server->responses.authBody = R"(
++ {
++ "device_code": "a-device-code",
++ "verification_uri": "a-verification-uri",
++ "expires_in": 1800
++ })";
++ checkAuthorizationError();
++
++ // Missing device code value
++ server->responses.authBody = R"(
++ {
++ "user_code": "a-user-code",
++ "device_code": "",
++ "verification_uri": "a-verification-uri",
++ "expires_in": 1800
++ })";
++ checkAuthorizationError();
++
++ // Missing device code field
++ server->responses.authBody = R"(
++ {
++ "user_code": "a-user-code",
++ "verification_uri": "a-verification-uri",
++ "expires_in": 1800
++ })";
++ checkAuthorizationError();
++
++ // Missing expiration value
++ server->responses.authBody = R"(
++ {
++ "user_code": "a-user-code",
++ "device_code": "a-device-code",
++ "verification_uri": "a-verification-uri",
++ "expires_in": 0
++ })";
++ checkAuthorizationError();
++
++ // Missing expiration field
++ server->responses.authBody = R"(
++ {
++ "user_code": "a-user-code",
++ "device_code": "a-device-code",
++ "verification_uri": "a-verification-uri"
++ })";
++ checkAuthorizationError();
++
++ // Missing verification uri value
++ server->responses.authBody = R"(
++ {
++ "user_code": "a-user-code",
++ "device_code": "a-device-code",
++ "verification_uri": "",
++ "expires_in": 1800
++ })";
++ checkAuthorizationError();
++
++ // Missing verification uri field
++ server->responses.authBody = R"(
++ {
++ "user_code": "a-user-code",
++ "device_code": "a-device-code",
++ "expires_in": 1800
++ })";
++ checkAuthorizationError();
++ }
++ QT_WARNING_POP
++
++ void tst_OAuth2DeviceFlow::tokenRequestErrors()
++ {
++ auto server = createAuthorizationServer<WebServer>(Responses::defaults());
++
++ DeviceFlow oauth2;
++ oauth2.flowPrivate()->useAutoTestDurations = true;
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(server->tokenEndpoint());
++
++ QSignalSpy requestFailedSpy(&oauth2, &QAbstractOAuth::requestFailed);
++ QSignalSpy grantedSpy(&oauth2, &QOAuth2DeviceAuthorizationFlow::granted);
++ QSignalSpy statusSpy(&oauth2, &QAbstractOAuth2::statusChanged);
++ auto clearTestVariables = [&](){
++ requestFailedSpy.clear();
++ grantedSpy.clear();
++ statusSpy.clear();
++ server->receivedAuthorizationRequests.clear();
++ server->receivedTokenRequests.clear();
++ };
++
++ // Invalid access token response
++ server->responses.tokenHttpStatus = Responses::OK_200;
++ server->responses.tokenBody = "not the expected json response";
++ expectWarning("Token request failed");
++ oauth2.grant();
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(server->receivedAuthorizationRequests.size(), 1);
++ QCOMPARE(server->receivedTokenRequests.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ServerError);
++ QVERIFY(grantedSpy.isEmpty());
++ QCOMPARE(statusSpy.size(), 1);
++ QCOMPARE(statusSpy.at(0).at(0).value<Status>(), Status::TemporaryCredentialsReceived);
++ QCOMPARE(oauth2.status(), Status::TemporaryCredentialsReceived);
++
++ // Access token error response (RFC 6749 section 5.2)
++ server->responses.tokenBody = R"(
++ {
++ "error": "an-error",
++ "error_description": "an-error-description",
++ "error_uri": "an-error-uri"
++ }
++ )";
++ server->responses.tokenHttpStatus = Responses::BR_400;
++ clearTestVariables();
++ expectWarning("Token request failed");
++ oauth2.grant();
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::ServerError);
++ QCOMPARE(server->receivedAuthorizationRequests.size(), 1);
++ QCOMPARE(server->receivedTokenRequests.size(), 1);
++ QVERIFY(grantedSpy.isEmpty());
++ QCOMPARE(statusSpy.size(), 2);
++ QCOMPARE(statusSpy.at(0).at(0).value<Status>(), Status::NotAuthenticated);
++ QCOMPARE(statusSpy.at(1).at(0).value<Status>(), Status::TemporaryCredentialsReceived);
++ QCOMPARE(oauth2.status(), Status::TemporaryCredentialsReceived);
++
++ // Missing access token
++ server->responses.tokenBody = R"(
++ {
++ "refresh_token": "a-refresh-token",
++ "token_type": "bearer",
++ "expires_in": 3600
++ })";
++ server->responses.tokenHttpStatus = Responses::OK_200;
++ clearTestVariables();
++ expectWarning("token not received");
++ oauth2.grant();
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(server->receivedTokenRequests.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::OAuthTokenNotFoundError);
++ QCOMPARE(grantedSpy.size(), 0);
++ QCOMPARE(oauth2.status(), Status::TemporaryCredentialsReceived);
++
++ // authorization_pending
++ server->responses.tokenBody = Responses::tokenAuthorizationPending;
++ server->responses.tokenHttpStatus = Responses::BR_400;
++ clearTestVariables();
++ oauth2.grant();
++ // Verify that retries (polls) are received (more than one token poll)
++ QTRY_VERIFY(server->receivedTokenRequests.size() > 1);
++ QCOMPARE(requestFailedSpy.size(), 0);
++ // Change to OK response and verify token is handled successfully
++ server->responses.tokenHttpStatus = Responses::OK_200;
++ server->responses.tokenBody = Responses::tokenSuccess;
++ QTRY_COMPARE(oauth2.status(), Status::Granted);
++
++ // slow_down
++ server->responses.tokenBody = R"(
++ {
++ "error": "slow_down",
++ "error_description": "You're polling too fast"
++ }
++ )";
++ server->responses.tokenHttpStatus = Responses::BR_400;
++ clearTestVariables();
++ oauth2.grant();
++ // Verify that retries (polls) are received (more than one token poll)
++ QTRY_VERIFY(server->receivedTokenRequests.size() > 1);
++ QCOMPARE(requestFailedSpy.size(), 0);
++ // Change to OK response and verify token is handled successfully
++ server->responses.tokenHttpStatus = Responses::OK_200;
++ server->responses.tokenBody = Responses::tokenSuccess;
++ QTRY_COMPARE(oauth2.status(), Status::Granted);
++
++ // Failed access token refresh (missing access token)
++ server->responses.tokenBody = R"(
++ {
++ "token_type": "bearer",
++ "expires_in": 3600
++ })";
++ server->responses.tokenHttpStatus = Responses::OK_200;
++ clearTestVariables();
++ expectWarning("token not received");
++ oauth2.refreshTokens();
++ QCOMPARE(oauth2.status(), QAbstractOAuth2::Status::RefreshingToken);
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(server->receivedTokenRequests.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::OAuthTokenNotFoundError);
++ QCOMPARE(oauth2.status(), Status::Granted); // Because we still have valid access token
++
++ // Network error
++ clearTestVariables();
++ server->server->close();
++ expectWarning("network error");
++ oauth2.grant();
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::NetworkError);
++ QCOMPARE(grantedSpy.size(), 0);
++ QCOMPARE(oauth2.status(), Status::NotAuthenticated);
++ }
++
++ void tst_OAuth2DeviceFlow::nonce()
++ {
++ const auto nonce = "a_nonce"_ba;
++ auto server = createAuthorizationServer<WebServer>(Responses::defaults());
++
++ QOAuth2DeviceAuthorizationFlow oauth2;
++ oauth2.setRequestedScopeTokens({"openid"});
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(server->tokenEndpoint());
++
++ // Verify that nonce is set to authorization request when appropriate
++ oauth2.setNonce(nonce);
++ oauth2.setRequestedScopeTokens({"scope_item1"});
++
++ // -- Nonce is always included
++ oauth2.setNonceMode(QAbstractOAuth2::NonceMode::Enabled);
++ oauth2.grant();
++ QTRY_COMPARE(server->receivedAuthorizationRequests.size(), 1);
++ QUrlQuery parameters;
++ parameters.setQuery(server->receivedAuthorizationRequests.at(0).body);
++ QCOMPARE(parameters.queryItemValue(u"nonce"_s).toUtf8(), nonce);
++
++ // -- Nonce is never included
++ oauth2.setNonceMode(QAbstractOAuth2::NonceMode::Disabled);
++ server->receivedAuthorizationRequests.clear();
++ oauth2.grant();
++ QTRY_COMPARE(server->receivedAuthorizationRequests.size(), 1);
++ parameters.setQuery(server->receivedAuthorizationRequests.at(0).body);
++ QVERIFY(parameters.queryItemValue(u"nonce"_s).toUtf8().isEmpty());
++
++ // -- Nonce is included if scope contains 'openid'
++ oauth2.setNonceMode(QAbstractOAuth2::NonceMode::Automatic);
++ server->receivedAuthorizationRequests.clear();
++ oauth2.grant();
++ QTRY_COMPARE(server->receivedAuthorizationRequests.size(), 1);
++ parameters.setQuery(server->receivedAuthorizationRequests.at(0).body);
++ QVERIFY(parameters.queryItemValue(u"nonce"_s).toUtf8().isEmpty());
++
++ oauth2.setRequestedScopeTokens({"scope_item1", "openid"});
++ server->receivedAuthorizationRequests.clear();
++ oauth2.grant();
++ QTRY_COMPARE(server->receivedAuthorizationRequests.size(), 1);
++ parameters.setQuery(server->receivedAuthorizationRequests.at(0).body);
++ QCOMPARE(parameters.queryItemValue(u"nonce"_s).toUtf8(), nonce);
++
++ // -- Clear nonce, one should be generated
++ oauth2.setNonce("");
++ QVERIFY(oauth2.nonce().isEmpty());
++ server->receivedAuthorizationRequests.clear();
++ oauth2.grant();
++ QTRY_COMPARE(server->receivedAuthorizationRequests.size(), 1);
++ QVERIFY(!oauth2.nonce().isEmpty());
++ parameters.setQuery(server->receivedAuthorizationRequests.at(0).body);
++ QCOMPARE(parameters.queryItemValue(u"nonce"_s).toUtf8(), oauth2.nonce());
++ }
++
++ void tst_OAuth2DeviceFlow::idToken()
++ {
++ auto server = createAuthorizationServer<WebServer>(Responses::defaults());
++
++ DeviceFlow oauth2;
++ oauth2.flowPrivate()->useAutoTestDurations = true;
++ oauth2.setRequestedScopeTokens({"openid"});
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(server->tokenEndpoint());
++
++ QSignalSpy idTokenSpy(&oauth2, &QAbstractOAuth2::idTokenChanged);
++ QSignalSpy requestFailedSpy(&oauth2, &QAbstractOAuth::requestFailed);
++
++ // Verify default token is empty
++ QVERIFY(oauth2.idToken().isEmpty());
++
++ // Test without openid and verify idToken doesn't change
++ oauth2.setRequestedScopeTokens({"read"});
++ oauth2.grant();
++ QTRY_COMPARE(oauth2.status(), Status::Granted);
++ QVERIFY(idTokenSpy.isEmpty());
++ QVERIFY(oauth2.idToken().isEmpty());
++
++ // Test with openid
++ // Note: using a proper JWT or setting the matching 'nonce' is not required for this tests
++ // purpose as we don't currently validate the received token, but no harm in being thorough
++ auto idToken = createSignedJWT({}, {{"nonce"_L1, oauth2.nonce()}});
++ oauth2.setRequestedScopeTokens({"openid"});
++ server->responses.tokenBody = R"(
++ {
++ "access_token": "an-access-token",
++ "refresh_token": "a-refresh-token",
++ "token_type": "bearer",
++ "expires_in": 3600,
++ "id_token": ")" + idToken.toLatin1() +
++ "\"}";
++ oauth2.grant();
++
++ QTRY_COMPARE(oauth2.status(), Status::Granted);
++ QCOMPARE(oauth2.idToken(), idToken);
++ QCOMPARE(idTokenSpy.size(), 1);
++ QCOMPARE(idTokenSpy.at(0).at(0).toByteArray(), idToken);
++
++ // Test missing id_token error
++ QVERIFY(requestFailedSpy.isEmpty());
++ expectWarning("ID token not received");
++ server->responses.tokenBody = Responses::tokenSuccess;
++ oauth2.grant();
++ QTRY_COMPARE(requestFailedSpy.size(), 1);
++ QCOMPARE(requestFailedSpy.at(0).at(0).value<Error>(), Error::OAuthTokenNotFoundError);
++ QCOMPARE(oauth2.status(), Status::TemporaryCredentialsReceived);
++ // idToken is cleared on failure
++ QCOMPARE(idTokenSpy.size(), 2);
++ QVERIFY(oauth2.idToken().isEmpty());
++ }
++
++ void tst_OAuth2DeviceFlow::requestedScopeTokens_data()
++ {
++ const QByteArray f = "first";
++ const QByteArray s = "second";
++
++ QTest::addColumn<QSet<QByteArray>>("requested_scope");
++ QTest::addColumn<QSet<QByteArray>>("expected_requested_scope");
++
++ QTest::addRow("singlescope") << QSet{f} << QSet{f};
++ QTest::addRow("multiscope") << QSet{f, s} << QSet{f, s};
++ }
++
++ void tst_OAuth2DeviceFlow::requestedScopeTokens()
++ {
++ QFETCH(QSet<QByteArray>, requested_scope);
++ QFETCH(QSet<QByteArray>, expected_requested_scope);
++
++ auto server = createAuthorizationServer<WebServer>(Responses::defaults());
++
++ QOAuth2DeviceAuthorizationFlow oauth2;
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(server->tokenEndpoint());
++ QVERIFY(oauth2.requestedScopeTokens().isEmpty());
++
++ QSignalSpy requestedScopeTokensSpy(&oauth2, &QAbstractOAuth2::requestedScopeTokensChanged);
++ oauth2.setRequestedScopeTokens(requested_scope);
++
++ QCOMPARE(requestedScopeTokensSpy.size(), 1);
++ QCOMPARE(oauth2.requestedScopeTokens(), expected_requested_scope);
++ QCOMPARE(requestedScopeTokensSpy.at(0).at(0).value<QSet<QByteArray>>(),
++ expected_requested_scope);
++
++ oauth2.grant();
++ QTRY_COMPARE(server->receivedAuthorizationRequests.size(), 1);
++ QUrlQuery parameters(server->receivedAuthorizationRequests.at(0).body);
++ const auto scopeTokens = parameters.queryItemValue(u"scope"_s).toLatin1().split(' ');
++ QCOMPARE_EQ(scopeTokens.size(), requested_scope.size());
++ for (const auto &token : scopeTokens)
++ QVERIFY2(requested_scope.contains(token), token.data());
++ }
++
++ void tst_OAuth2DeviceFlow::grantedScopeTokens_data()
++ {
++ const QSet<QByteArray> requestedScopeTokens = {"first", "second"};
++ const QByteArray scope = "first second";
++ const QByteArray granted1 = "granted1";
++ const QByteArray granted2 = "granted2";
++ const QByteArray grantedJoined = granted1 + " " + granted2;
++ const QSet<QByteArray> grantedList = {granted1, granted2};
++
++ QTest::addColumn<QSet<QByteArray>>("requested_scope");
++ QTest::addColumn<QByteArray>("granted_scope");
++ QTest::addColumn<QSet<QByteArray>>("expected_granted_scope");
++
++ QTest::addRow("requested_scope_returned")
++ << requestedScopeTokens << scope << requestedScopeTokens;
++
++ QTest::addRow("differing_singlescope_returned")
++ << requestedScopeTokens << granted1 << QSet<QByteArray>{granted1};
++
++ QTest::addRow("differing_multiscope_returned")
++ << requestedScopeTokens << grantedJoined << grantedList;
++
++ QTest::addRow("empty_scope_returned")
++ << requestedScopeTokens << ""_ba << requestedScopeTokens;
++ }
++
++ void tst_OAuth2DeviceFlow::grantedScopeTokens()
++ {
++ QFETCH(QSet<QByteArray>, requested_scope);
++ QFETCH(QByteArray, granted_scope);
++ QFETCH(QSet<QByteArray>, expected_granted_scope);
++
++ auto server = createAuthorizationServer<WebServer>(Responses::defaults());
++
++ DeviceFlow oauth2;
++ oauth2.flowPrivate()->useAutoTestDurations = true;
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(server->tokenEndpoint());
++
++ QSignalSpy grantedSpy(&oauth2, &QAbstractOAuth2::grantedScopeTokensChanged);
++ oauth2.setRequestedScopeTokens(requested_scope);
++
++ if (!granted_scope.isEmpty()) {
++ server->responses.tokenBody = R"(
++ {
++ "access_token": "an-access-token",
++ "refresh_token": "a-refresh-token",
++ "token_type": "bearer",
++ "expires_in": 3600,
++ "scope": ")" + granted_scope +
++ "\"}";
++ }
++ oauth2.grant();
++
++ QTRY_COMPARE(grantedSpy.size(), 1);
++ QCOMPARE(oauth2.grantedScopeTokens(), expected_granted_scope);
++ QCOMPARE(grantedSpy.at(0).at(0).value<QSet<QByteArray>>(), expected_granted_scope);
++ }
++
++ void tst_OAuth2DeviceFlow::refreshLeadTime_data()
++ {
++ QTest::addColumn<std::chrono::seconds>("refreshLeadTime");
++ QTest::addColumn<int>("expiresIn");
++ QTest::addColumn<std::chrono::seconds>("waitTimeForExpiration");
++ QTest::addColumn<bool>("autoRefresh");
++ QTest::addColumn<bool>("expectExpirationSignal");
++ QTest::addColumn<QString>("refreshToken");
++ QTest::addColumn<bool>("expectRefreshRequest");
++
++ const QString refreshToken = u"refreshToken"_s;
++
++ // wait-time: 20s - 18s = 2s, + 1s for robustness => 3s
++ QTest::addRow("validSetExpiration")
++ << 18s << 20 << 3s << true << true << refreshToken << true;
++
++ // wait-time calculation: 15s - 10s = 5s, + 1s for robustness => 6s
++ QTest::addRow("validCalculatedExpiration")
++ << 0s << 15 << 6s << true << true << refreshToken << true;
++
++ // wait-time: 5s - 10s = -5s => 2s minimum + 1s for robustness => 3s
++ QTest::addRow("tooShortCalculatedExpiration")
++ << 0s << 5 << 3s << true << true << refreshToken << true;
++
++ // wait-time: 5s - 10s = -5s => 2s minimum + 1s for robustness => 3s
++ QTest::addRow("tooShortSetExpiration")
++ << 10s << 5 << 3s << true << true << refreshToken << true;
++
++ // wait-time: 3s - 1s = 2s, +1s for robustness => 3s
++ QTest::addRow("leadTimeNearExpiration")
++ << 1s << 3 << 3s << true << true << refreshToken << true;
++
++ QTest::addRow("invalidExpirationTime")
++ << 1s << 0 << 3s << true << false << refreshToken << false;
++
++ // wait-time: 2s - 1s = 1s, => minimum 2s + 1s for robustness => 2s
++ QTest::addRow("autoRefreshDisabled")
++ << 1s << 2 << 3s << false << true << refreshToken << false;
++
++ QTest::addRow("emptyRefreshToken")
++ << 18s << 20 << 3s << true << true << QString() << false;
++ }
++
++ void tst_OAuth2DeviceFlow::refreshLeadTime()
++ {
++ QFETCH(std::chrono::seconds, refreshLeadTime);
++ QFETCH(int, expiresIn);
++ QFETCH(std::chrono::seconds, waitTimeForExpiration);
++ QFETCH(bool, autoRefresh);
++ QFETCH(bool, expectExpirationSignal);
++ QFETCH(QString, refreshToken);
++ QFETCH(bool, expectRefreshRequest);
++
++ auto server = createAuthorizationServer<WebServer>(Responses::defaults());
++
++ DeviceFlow oauth2;
++ oauth2.flowPrivate()->useAutoTestDurations = true;
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(server->tokenEndpoint());
++ oauth2.setRefreshLeadTime(refreshLeadTime);
++ oauth2.setAutoRefresh(autoRefresh);
++
++ QSignalSpy expiredSpy(&oauth2, &QAbstractOAuth2::accessTokenAboutToExpire);
++ QSignalSpy grantedSpy(&oauth2, &QAbstractOAuth2::grantedScopeTokensChanged);
++
++ server->responses.tokenBody = R"(
++ {
++ "access_token": "initial-access-token",
++ "token_type": "bearer",
++ "expires_in": ")" + QByteArray::number(expiresIn) + R"(",
++ "scope": "s",
++ "refresh_token": ")" + refreshToken.toLatin1() +
++ "\"}";
++ oauth2.grant();
++
++ QTRY_COMPARE(grantedSpy.size(), 1);
++ QCOMPARE(oauth2.token(), u"initial-access-token"_s);
++
++ // Clear initial token request
++ server->receivedTokenRequests.clear();
++
++ if (expectExpirationSignal) {
++ server->responses.tokenBody = R"(
++ {
++ "access_token": "refreshed-access-token",
++ "token_type": "bearer",
++ "expires_in": 3600,
++ "scope": "s",
++ "refresh_token": "a-refresh-token"
++ })";
++ QTRY_COMPARE_WITH_TIMEOUT(expiredSpy.size(), 1, waitTimeForExpiration);
++ if (expectRefreshRequest) {
++ QTRY_COMPARE(oauth2.token(), "refreshed-access-token"_L1);
++ QCOMPARE(server->receivedTokenRequests.size(), 1);
++ QCOMPARE(expiredSpy.size(), 1);
++ } else {
++ // Refresh request isn't expected. To be sure that it isn't sent, allow a bit time
++ // for the network stack to process before testing that it indeed wasn't sent
++ QTest::qWait(100);
++ QCOMPARE(server->receivedTokenRequests.size(), 0);
++ }
++ }
++ }
++
++ void tst_OAuth2DeviceFlow::destruction_data()
++ {
++ QTest::addColumn<QNetworkAccessManager *>("qnam");
++
++ QTest::addRow("no qnam supplied") << static_cast<QNetworkAccessManager*>(nullptr);
++ QTest::addRow("default qnam") << new QNetworkAccessManager;
++ auto qnamWithAutoDelete = new QNetworkAccessManager;
++ qnamWithAutoDelete->setAutoDeleteReplies(true);
++ QTest::addRow("autodeleting qnam") << qnamWithAutoDelete;
++ }
++
++ void tst_OAuth2DeviceFlow::destruction()
++ {
++ // Test destroying the device flow class in different stages of the flow
++ // and verify that things destruct cleanly
++ QFETCH(QNetworkAccessManager *, qnam);
++ std::unique_ptr<QNetworkAccessManager> accessManager(qnam);
++
++ auto responses = Responses::defaults();
++ responses.authBody = Responses::authorizationResponseWithTimes(1, 5);
++ responses.tokenBody = Responses::tokenAuthorizationPending;
++ auto server = createAuthorizationServer<WebServer>(responses);
++
++ const auto newFlow = [&]() -> QOAuth2DeviceAuthorizationFlow * {
++ QOAuth2DeviceAuthorizationFlow *flow =
++ new QOAuth2DeviceAuthorizationFlow(accessManager.get());
++ flow->setAuthorizationUrl(server->authorizationEndpoint());
++ flow->setTokenUrl(server->tokenEndpoint());
++ return flow;
++ };
++
++ std::unique_ptr<QOAuth2DeviceAuthorizationFlow> oauth2;
++ oauth2.reset(newFlow());
++
++ // Delete right after creation
++ oauth2.reset(nullptr);
++
++ // Delete while authorization request is out
++ oauth2.reset(newFlow());
++ oauth2->grant();
++ oauth2.reset(nullptr);
++
++ // Delete while polling
++ oauth2.reset(newFlow());
++ oauth2->grant();
++ QTRY_VERIFY(server->receivedTokenRequests.size() >= 1);
++ oauth2.reset(nullptr);
++ }
++
++ void tst_OAuth2DeviceFlow::changeNetworkAccessManager()
++ {
++ auto responses = Responses::defaults();
++ responses.authBody = Responses::authorizationResponseWithTimes(100, 1000);
++ auto server = createAuthorizationServer<WebServer>(Responses::defaults());
++
++ QNetworkAccessManager *qnam0 = nullptr;
++ QNetworkAccessManager qnam1;
++ QNetworkAccessManager qnam2;
++
++ DeviceFlow oauth2;
++ oauth2.flowPrivate()->useAutoTestDurations = true;
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(server->tokenEndpoint());
++
++ bool stopPollingAfterAuthorization = true;
++ connect(&oauth2, &QAbstractOAuth::statusChanged, this, [&](Status status) {
++ if (status == Status::TemporaryCredentialsReceived && stopPollingAfterAuthorization)
++ oauth2.stopTokenPolling();
++ });
++
++ // Change QNAM between authorization and polling
++ stopPollingAfterAuthorization = true;
++ oauth2.setNetworkAccessManager(&qnam1);
++ oauth2.grant();
++ QTRY_COMPARE(oauth2.status(), Status::TemporaryCredentialsReceived);
++ oauth2.setNetworkAccessManager(&qnam2);
++ oauth2.startTokenPolling();
++ QTRY_COMPARE(oauth2.status(), Status::Granted);
++
++ // Set QNAM to nullptr, which should trigger creation of a new (internal) one
++ oauth2.setNetworkAccessManager(qnam0);
++ stopPollingAfterAuthorization = false;
++ oauth2.grant();
++ QTRY_COMPARE(oauth2.status(), Status::Granted);
++
++ // Change QNAM right after sending authorization request. The authorization
++ // works because the first QNAM isn't an internal one (which would get deleted)
++ oauth2.setNetworkAccessManager(&qnam1);
++ oauth2.grant();
++ oauth2.setNetworkAccessManager(&qnam2);
++ QTRY_COMPARE(oauth2.status(), Status::Granted);
++
++ // Change QNAM right after sending authorization request. This time authorization
++ // won't work because the internally created QNAM will get deleted. This is a
++ // corner case, but must not crash etc.
++ oauth2.setNetworkAccessManager(nullptr);
++ oauth2.grant();
++ oauth2.setNetworkAccessManager(&qnam2);
++ QTest::qWait(200ms);
++ QCOMPARE(oauth2.status(), Status::NotAuthenticated);
++ // restart with the new QNAM
++ oauth2.grant();
++ QTRY_COMPARE(oauth2.status(), Status::Granted);
++ }
++
++ #ifndef QT_NO_SSL
++ void tst_OAuth2DeviceFlow::tlsAuthentication()
++ {
++ if (!QSslSocket::supportsSsl())
++ QSKIP("This test will fail because the backend does not support TLS");
++
++ #ifdef Q_OS_MACOS
++ #if !QT_APPLE_SDK_EQUAL_OR_ABOVE(MACOS(15), IOS(18))
++ if (QOperatingSystemVersion::current() >= QOperatingSystemVersion::MacOSSequoia
++ && QSslSocket::activeBackend() == QLatin1String("securetransport")) {
++ // Built with SDK < 15, with file-based keychains that no longer work on macOS >= 15.
++ QSKIP("SecureTransport will block the test server while accessing the login keychain");
++ }
++ #endif
++ #endif // Q_OS_MACOS
++
++ auto rollback = useTemporaryKeychain();
++
++ // erros may vary, depending on backend
++ const QSet<QSslError::SslError> expectedErrors{ QSslError::SelfSignedCertificate,
++ QSslError::CertificateUntrusted,
++ QSslError::HostNameMismatch };
++ auto serverConfig = createSslConfiguration(testDataDir + "certs/selfsigned-server.key",
++ testDataDir + "certs/selfsigned-server.crt");
++
++ auto responses = Responses::defaults();
++ // SSL establishment can take awhile, use one full second for poll interval
++ responses.authBody = Responses::authorizationResponseWithTimes(1, 10);
++ auto server = createAuthorizationServer<TlsWebServer>(responses, serverConfig);
++
++ server->server->setExpectedSslErrors(expectedErrors);
++ auto clientConfig = createSslConfiguration(testDataDir + "certs/selfsigned-client.key",
++ testDataDir + "certs/selfsigned-client.crt");
++
++ QNetworkAccessManager qnam;
++ QOAuth2DeviceAuthorizationFlow oauth2;
++ oauth2.setNetworkAccessManager(&qnam);
++ oauth2.setSslConfiguration(clientConfig);
++ oauth2.setAuthorizationUrl(server->authorizationEndpoint());
++ oauth2.setTokenUrl(server->tokenEndpoint());
++
++ connect(&qnam, &QNetworkAccessManager::sslErrors, this,
++ [&expectedErrors](QNetworkReply *r, const QList<QSslError> &errors) {
++ // On some Windows machines the test might report all
++ // three expected errors.
++ QCOMPARE_GE(errors.size(), 2);
++ QCOMPARE_LE(errors.size(), 3);
++ for (const auto &err : errors)
++ QVERIFY(expectedErrors.contains(err.error()));
++ r->ignoreSslErrors();
++ });
++
++ QSignalSpy grantedSpy(&oauth2, &QOAuth2DeviceAuthorizationFlow::granted);
++ oauth2.grant();
++ QTRY_COMPARE(grantedSpy.size(), 1);
++ QCOMPARE(oauth2.token(), "an-access-token"_L1);
++ }
++ #endif // !QT_NO_SSL
++
++ QTEST_MAIN(tst_OAuth2DeviceFlow)
++ #include "tst_oauth2deviceflow.moc"