summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt2
-rw-r--r--dependencies.yaml2
-rw-r--r--src/oauth/CMakeLists.txt9
-rw-r--r--src/oauth/configure.cmake11
-rw-r--r--src/oauth/doc/images/oauth2-flow-details.webpbin0 -> 81778 bytes
-rw-r--r--src/oauth/doc/images/oauth2-stages.webpbin0 -> 18868 bytes
-rw-r--r--src/oauth/doc/qtnetworkauth.qdocconf6
-rw-r--r--src/oauth/doc/snippets/CMakeLists.txt16
-rw-r--r--src/oauth/doc/snippets/src_oauth_replyhandlers.cpp113
-rw-r--r--src/oauth/doc/src/qtnetworkauth-oauth2-overview.qdoc156
-rw-r--r--src/oauth/doc/src/qtnetworkauth.qdoc6
-rw-r--r--src/oauth/qabstractoauth2.cpp3
-rw-r--r--src/oauth/qabstractoauth2_p.h3
-rw-r--r--src/oauth/qoauth1.cpp1
-rw-r--r--src/oauth/qoauth1signature.cpp3
-rw-r--r--src/oauth/qoauth2authorizationcodeflow.cpp118
-rw-r--r--src/oauth/qoauth2authorizationcodeflow.h11
-rw-r--r--src/oauth/qoauth2authorizationcodeflow_p.h7
-rw-r--r--src/oauth/qoauthhttpserverreplyhandler.cpp241
-rw-r--r--src/oauth/qoauthhttpserverreplyhandler_p.h6
-rw-r--r--src/oauth/qoauthoobreplyhandler.cpp6
-rw-r--r--src/oauth/qoauthoobreplyhandler.h2
-rw-r--r--src/oauth/qoauthoobreplyhandler_p.h28
-rw-r--r--src/oauth/qoauthurischemereplyhandler.cpp362
-rw-r--r--src/oauth/qoauthurischemereplyhandler.h46
-rw-r--r--tests/auto/CMakeLists.txt3
-rw-r--r--tests/auto/oauth2/tst_oauth2.cpp87
-rw-r--r--tests/auto/oauthhttpserverreplyhandler/tst_oauthhttpserverreplyhandler.cpp179
-rw-r--r--tests/auto/oauthurischemereplyhandler/CMakeLists.txt12
-rw-r--r--tests/auto/oauthurischemereplyhandler/tst_oauthurischemereplyhandler.cpp201
30 files changed, 1594 insertions, 46 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index a5477f6..de9c2e5 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -14,7 +14,7 @@ project(QtNetworkAuth
)
find_package(Qt6 ${PROJECT_VERSION} CONFIG REQUIRED COMPONENTS BuildInternals Core Network) # special case
-find_package(Qt6 ${PROJECT_VERSION} QUIET CONFIG OPTIONAL_COMPONENTS Widgets) # special case
+find_package(Qt6 ${PROJECT_VERSION} QUIET CONFIG OPTIONAL_COMPONENTS Widgets Gui) # special case
qt_internal_project_setup()
if(NOT TARGET Qt::Network)
diff --git a/dependencies.yaml b/dependencies.yaml
index a391824..c41fea5 100644
--- a/dependencies.yaml
+++ b/dependencies.yaml
@@ -1,4 +1,4 @@
dependencies:
../qtbase:
- ref: 4a984c15dde72e074b2d66e484387cbe0d53130d
+ ref: e011c3c4b023e5bf206271e642cb55029b614985
required: true
diff --git a/src/oauth/CMakeLists.txt b/src/oauth/CMakeLists.txt
index 4a47aed..4bde1cf 100644
--- a/src/oauth/CMakeLists.txt
+++ b/src/oauth/CMakeLists.txt
@@ -17,7 +17,7 @@ qt_internal_add_module(NetworkAuth
qoauth2authorizationcodeflow.cpp qoauth2authorizationcodeflow.h qoauth2authorizationcodeflow_p.h
qoauthglobal.h
qoauthhttpserverreplyhandler.cpp qoauthhttpserverreplyhandler.h qoauthhttpserverreplyhandler_p.h
- qoauthoobreplyhandler.cpp qoauthoobreplyhandler.h
+ qoauthoobreplyhandler.cpp qoauthoobreplyhandler.h qoauthoobreplyhandler_p.h
LIBRARIES
Qt::CorePrivate
PUBLIC_LIBRARIES
@@ -27,6 +27,13 @@ qt_internal_add_module(NetworkAuth
Qt::CorePrivate
)
+qt_internal_extend_target(NetworkAuth CONDITION QT_FEATURE_urischeme_replyhandler
+ SOURCES
+ qoauthurischemereplyhandler.cpp qoauthurischemereplyhandler.h
+ LIBRARIES
+ Qt::Gui
+)
+
#### Keys ignored in scope 1:.:.:oauth.pro:<TRUE>:
# MODULE = "networkauth"
qt_internal_add_docs(NetworkAuth
diff --git a/src/oauth/configure.cmake b/src/oauth/configure.cmake
new file mode 100644
index 0000000..4f7e8ce
--- /dev/null
+++ b/src/oauth/configure.cmake
@@ -0,0 +1,11 @@
+# Copyright (C) 2024 The Qt Company Ltd.
+# SPDX-License-Identifier: BSD-3-Clause
+
+qt_feature("urischeme_replyhandler" PRIVATE
+ LABEL "URI Scheme Reply Handler"
+ CONDITION QT_FEATURE_gui
+)
+
+qt_configure_add_summary_section(NAME "Qt NetworkAuth")
+qt_configure_add_summary_entry(ARGS "urischeme_replyhandler")
+qt_configure_end_summary_section()
diff --git a/src/oauth/doc/images/oauth2-flow-details.webp b/src/oauth/doc/images/oauth2-flow-details.webp
new file mode 100644
index 0000000..10af724
--- /dev/null
+++ b/src/oauth/doc/images/oauth2-flow-details.webp
Binary files differ
diff --git a/src/oauth/doc/images/oauth2-stages.webp b/src/oauth/doc/images/oauth2-stages.webp
new file mode 100644
index 0000000..8babfa7
--- /dev/null
+++ b/src/oauth/doc/images/oauth2-stages.webp
Binary files differ
diff --git a/src/oauth/doc/qtnetworkauth.qdocconf b/src/oauth/doc/qtnetworkauth.qdocconf
index 23d021a..acd5d35 100644
--- a/src/oauth/doc/qtnetworkauth.qdocconf
+++ b/src/oauth/doc/qtnetworkauth.qdocconf
@@ -22,14 +22,14 @@ qhp.QtNetworkAuth.subprojects.classes.sortPages = true
tagfile = qtnetworkauth.tags
-depends += qtcore qtnetwork qtdoc qtcmake
+depends += qtcore qtnetwork qtdoc qtcmake qtgui
headerdirs += ..
sourcedirs += ..
-#imagedirs += images
+imagedirs += images
examplesinstallpath = oauth
-exampledirs += ../../../examples/oauth
+exampledirs += ../../../examples/oauth snippets
#manifestmeta.highlighted.names = "QtNetworkAuth/Twitter Timeline Example"
diff --git a/src/oauth/doc/snippets/CMakeLists.txt b/src/oauth/doc/snippets/CMakeLists.txt
new file mode 100644
index 0000000..7018e8f
--- /dev/null
+++ b/src/oauth/doc/snippets/CMakeLists.txt
@@ -0,0 +1,16 @@
+# Copyright (C) 2024 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
+
+cmake_minimum_required(VERSION 3.16)
+
+project(networkauth_cppsnippets)
+
+find_package(Qt6 REQUIRED COMPONENTS Core NetworkAuth Gui)
+
+qt_standard_project_setup(REQUIRES 6.5)
+
+add_executable(networkauth_cppsnippets
+ src_oauth_replyhandlers.cpp
+)
+
+target_link_libraries(networkauth_cppsnippets PRIVATE Qt6::Core Qt6::NetworkAuth Qt6::Gui)
diff --git a/src/oauth/doc/snippets/src_oauth_replyhandlers.cpp b/src/oauth/doc/snippets/src_oauth_replyhandlers.cpp
new file mode 100644
index 0000000..9705c37
--- /dev/null
+++ b/src/oauth/doc/snippets/src_oauth_replyhandlers.cpp
@@ -0,0 +1,113 @@
+// Copyright (C) 2024 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
+
+#include <QtNetworkAuth/qoauth2authorizationcodeflow.h>
+#include <QtNetworkAuth/qoauthhttpserverreplyhandler.h>
+#include <QtNetworkAuth/qoauthurischemereplyhandler.h>
+
+#include <QtNetwork/qnetworkrequestfactory.h>
+
+#include <QtGui/qdesktopservices.h>
+#include <QtGui/qguiapplication.h>
+
+#include <QtCore/qobject.h>
+#include <QtCore/qurl.h>
+
+using namespace Qt::StringLiterals;
+
+class HttpServerExample : public QObject
+{
+ Q_OBJECT
+public:
+
+ void setup()
+ {
+ //! [httpserver-oauth-setup]
+ m_oauth.setAuthorizationUrl(QUrl("https://some.authorization.service/v3/authorize"_L1));
+ m_oauth.setAccessTokenUrl(QUrl("https://some.authorization.service/v3/access_token"_L1));
+ m_oauth.setClientIdentifier("a_client_id"_L1);
+ m_oauth.setScope("read"_L1);
+
+ m_handler = new QOAuthHttpServerReplyHandler(1234, this);
+
+ connect(&m_oauth, &QAbstractOAuth::authorizeWithBrowser, this, &QDesktopServices::openUrl);
+ connect(&m_oauth, &QAbstractOAuth::granted, this, [this]() {
+ // Here we use QNetworkRequestFactory to store the access token
+ m_api.setBearerToken(m_oauth.token().toLatin1());
+ m_handler->close();
+ });
+ //! [httpserver-oauth-setup]
+
+ //! [httpserver-handler-setup]
+ m_oauth.setReplyHandler(m_handler);
+
+ // Initiate the authorization
+ if (m_handler->isListening()) {
+ m_oauth.grant();
+ }
+ //! [httpserver-handler-setup]
+ }
+
+private:
+ //! [httpserver-variables]
+ QOAuth2AuthorizationCodeFlow m_oauth;
+ QOAuthHttpServerReplyHandler *m_handler = nullptr;
+ //! [httpserver-variables]
+ QNetworkRequestFactory m_api;
+};
+
+class UriSchemeExample : public QObject
+{
+ Q_OBJECT
+public:
+
+ void setup()
+ {
+ //! [uri-oauth-setup]
+ m_oauth.setAuthorizationUrl(QUrl("https://some.authorization.service/v3/authorize"_L1));
+ m_oauth.setAccessTokenUrl(QUrl("https://some.authorization.service/v3/access_token"_L1));
+ m_oauth.setClientIdentifier("a_client_id"_L1);
+ m_oauth.setScope("read"_L1);
+
+ connect(&m_oauth, &QAbstractOAuth::authorizeWithBrowser, this, &QDesktopServices::openUrl);
+ connect(&m_oauth, &QAbstractOAuth::granted, this, [this]() {
+ // Here we use QNetworkRequestFactory to store the access token
+ m_api.setBearerToken(m_oauth.token().toLatin1());
+ m_handler.close();
+ });
+ //! [uri-oauth-setup]
+
+ //! [uri-handler-setup]
+ m_handler.setRedirectUrl(QUrl{"com.my.app:/oauth2redirect"_L1});
+ m_oauth.setReplyHandler(&m_handler);
+
+ // Initiate the authorization
+ if (m_handler.listen()) {
+ m_oauth.grant();
+ }
+ //! [uri-handler-setup]
+ }
+
+private:
+
+ //! [uri-variables]
+ QOAuth2AuthorizationCodeFlow m_oauth;
+ QOAuthUriSchemeReplyHandler m_handler;
+ //! [uri-variables]
+ QNetworkRequestFactory m_api;
+};
+
+int main(int argc, char *argv[])
+{
+ QGuiApplication a(argc, argv);
+
+ HttpServerExample httpServer;
+ httpServer.setup();
+
+ UriSchemeExample uriScheme;
+ uriScheme.setup();
+
+ return a.exec();
+}
+
+#include "src_oauth_replyhandlers.moc"
diff --git a/src/oauth/doc/src/qtnetworkauth-oauth2-overview.qdoc b/src/oauth/doc/src/qtnetworkauth-oauth2-overview.qdoc
new file mode 100644
index 0000000..f84730c
--- /dev/null
+++ b/src/oauth/doc/src/qtnetworkauth-oauth2-overview.qdoc
@@ -0,0 +1,156 @@
+// Copyright (C) 2024 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GFDL-1.3-no-invariants-only
+
+/*!
+\page qt-oauth2-overview.html
+
+\title Qt OAuth2 Overview
+\ingroup explanations-networkauth
+\brief An overview of QtNetworkAuth OAuth2
+
+\section1 OAuth2
+
+\l {https://datatracker.ietf.org/doc/html/rfc6749}{RFC 6749 OAuth 2.0}
+defines an authorization framework which enables resource authorization
+without exposing sensitive user credentials such as passwords.
+
+The OAuth2 framework defines several client types (public and confidential)
+as well as flows (implicit, authorization code, and several others).
+For typical Qt applications the client type should be considered as
+\e {public native} application. The \e {public} implies that the
+application isn't trusted to hold secrets, such as passwords, embedded
+within the shipped binary.
+
+\l {https://datatracker.ietf.org/doc/html/rfc8252}{RFC 8252 OAuth 2.0 for Native Apps}
+further defines the best practices for such applications. Among other things,
+it defines the
+\l {https://datatracker.ietf.org/doc/html/rfc8252#section-6}{Authorization Code Flow}
+as the recommended flow. QtNetworkAuth provides a concrete implementation for
+this flow, and it is also the focus of this documentation.
+
+\section1 Qt OAuth2 Classes
+
+QtNetworkAuth provides both concrete and abstract OAuth2 classes.
+The abstract classes are intended for implementing custom flows, while
+the concrete classes provide a concrete implementation.
+
+To implement an OAuth2 flow with QtNetworkAuth, two classes are needed:
+\list
+ \li A \e {OAuth2 flow implementation} class provides the main API,
+ and is the orchestrator of the flow. It usually owns one reply handler.
+ The abstract class is QAbstractOAuth2, and the concrete
+ implementation is QOAuth2AuthorizationCodeFlow.
+ \li A \e {Reply handler} class which handles replies from an authorization
+ server. With authorization code flow, these include authorization,
+ access token request, and access token refresh.
+ The results of processing a reply are further handled by the
+ flow class. The reply handler abstract class is QAbstractOAuthReplyHandler,
+ and the concrete classes are QOAuthHttpServerReplyHandler and
+ QOAuthUriSchemeReplyHandler.
+\endlist
+
+\section1 Authorization Code Flow
+
+The \l {https://datatracker.ietf.org/doc/html/rfc6749#section-1.3.1}{authorization code flow}
+is the
+\l {https://datatracker.ietf.org/doc/html/rfc8252#section-6}{recommended OAuth2 flow}
+for native applications like Qt applications.
+
+The following code snippet provides an example setup:
+
+\snippet src_oauth_replyhandlers.cpp uri-variables
+\snippet src_oauth_replyhandlers.cpp uri-oauth-setup
+\snippet src_oauth_replyhandlers.cpp uri-handler-setup
+
+\section2 Stages
+
+The Authorization Code Flow has two main stages: resource authorization
+(including any necessary user authentication) followed up by an access
+token request. These are optionally followed by access token usage and
+access token refreshing. The following figure illustrates these stages:
+
+\image oauth2-stages.webp
+
+\list
+ \li In authorization stage, the user is authenticated, and
+ the user authorizes the access to resources. This requires browser
+ interaction by the user.
+ \li After the authorization the received authorization
+ code is used to request an access token, and optionally a refresh
+ token.
+ \li Once the access token is acquired, the application uses it to
+ access the resources of interest. The access token is included
+ in the resource requests, and it is up to the resource server
+ to verify the token's validity.
+ \l {https://datatracker.ietf.org/doc/html/rfc6750}{There are several
+ ways to include the token as part of the requests}, but
+ including it in the \l {https://datatracker.ietf.org/doc/html/rfc6750#section-2.1}
+ {HTTP \c Authorization header} is arguably the most common.
+ \li Access token refreshing. Access tokens typically expire relatively
+ quickly, say in one hour. If the application received a refresh token
+ in addition to the access token, the refresh token can be used to
+ request a new access token. Refresh tokens are long-lived and applications
+ can persist them to avoid the need for a new authorization stage
+ (and thus another browser interaction).
+\endlist
+
+\section2 Details and Customization
+
+OAuth2 flows are dynamic and following the details can
+be tricky at first. The figure below illustrates the main details
+of a successful authorization code flow.
+
+\image oauth2-flow-details.webp
+
+For clarity the figure omits some less used signals, but altogether
+illustrates the details and main customization points. The customization
+points are the various signals/slots the application can catch (and call),
+as well as the callback which is settable with
+\l QAbstractOAuth::setModifyParametersFunction().
+
+\section2 Choosing A Reply Handler
+
+The decision on which reply hander to use, or to implement,
+is dependent on the
+\l {https://datatracker.ietf.org/doc/html/rfc6749#appendix-A.6}{redirect_uri}
+used. The \c redirect_uri is where the browser is redirected upon concluding
+the authorization stage.
+
+In the context of native applications,
+\l {https://datatracker.ietf.org/doc/html/rfc8252#section-7}
+{RFC 8252 outlines three main types of URI schemes}:
+\c loopback, \c https, and private-use.
+
+\list
+ \li \l{https://datatracker.ietf.org/doc/html/rfc8252#section-7.1}{Private-use URIs}:
+ Can be used if the OS allows an application to register a custom URI
+ scheme. An attempt to open an URL with such custom scheme will open the
+ related native application. See \l QOAuthUriSchemeReplyHandler.
+ \li \l{https://datatracker.ietf.org/doc/html/rfc8252#section-7.2}{HTTPS URIs}:
+ Can be used if the OS allows the application to register a custom HTTPS
+ URL. An attempt to open this URL will open the related native
+ application. This scheme is recommended if the OS supports it.
+ See \l QOAuthUriSchemeReplyHandler.
+ \li \l{https://datatracker.ietf.org/doc/html/rfc8252#section-7.3}{Loopback Interfaces}:
+ These are commonly used for desktop applications, and applications
+ during development. The \l QOAuthHttpServerReplyHandler is designed to
+ handle these URIs by setting up a local server to handle the
+ redirection.
+\endlist
+
+The choice depends on several factors such as:
+\list
+ \li Redirect URIs supported by the authorization server vendor.
+ The support varies from vendor to vendor, and is often specific
+ to a particular client type and operating system. Also, the support
+ may vary depending on whether the application is published or not.
+ \li Redirect URI schemes supported by the target platform(s).
+ \li Application-specific usability, security, and other requirements.
+\endlist
+
+\quotation \l {https://datatracker.ietf.org/doc/html/rfc8252#section-7.2}
+ {RFC 8252 recommends using the \c https scheme} for
+security and usability advantages over the other methods.
+\endquotation
+
+*/
diff --git a/src/oauth/doc/src/qtnetworkauth.qdoc b/src/oauth/doc/src/qtnetworkauth.qdoc
index b449312..810d472 100644
--- a/src/oauth/doc/src/qtnetworkauth.qdoc
+++ b/src/oauth/doc/src/qtnetworkauth.qdoc
@@ -61,6 +61,12 @@
suspicious applications. Instead, the credentials are entered in a known
and trusted web interface.
+ \section1 Articles and Guides
+
+ \list
+ \li \l {Qt OAuth2 Overview}
+ \endlist
+
\section1 Licenses
Qt Network Authorization is available under commercial licenses from
diff --git a/src/oauth/qabstractoauth2.cpp b/src/oauth/qabstractoauth2.cpp
index 024a32a..d8b4283 100644
--- a/src/oauth/qabstractoauth2.cpp
+++ b/src/oauth/qabstractoauth2.cpp
@@ -114,6 +114,9 @@ const QString OAuth2::responseType = u"response_type"_s;
const QString OAuth2::scope = u"scope"_s;
const QString OAuth2::state = u"state"_s;
const QString OAuth2::tokenType = u"token_type"_s;
+const QString OAuth2::codeVerifier = u"code_verifier"_s;
+const QString OAuth2::codeChallenge = u"code_challenge"_s;
+const QString OAuth2::codeChallengeMethod = u"code_challenge_method"_s;
QAbstractOAuth2Private::QAbstractOAuth2Private(const QPair<QString, QString> &clientCredentials,
const QUrl &authorizationUrl,
diff --git a/src/oauth/qabstractoauth2_p.h b/src/oauth/qabstractoauth2_p.h
index 8db6ff3..ef88300 100644
--- a/src/oauth/qabstractoauth2_p.h
+++ b/src/oauth/qabstractoauth2_p.h
@@ -76,6 +76,9 @@ public:
static const QString scope;
static const QString state;
static const QString tokenType;
+ static const QString codeVerifier;
+ static const QString codeChallenge;
+ static const QString codeChallengeMethod;
};
};
diff --git a/src/oauth/qoauth1.cpp b/src/oauth/qoauth1.cpp
index e9def68..269bc4c 100644
--- a/src/oauth/qoauth1.cpp
+++ b/src/oauth/qoauth1.cpp
@@ -8,7 +8,6 @@
#include "qoauth1.h"
#include "qoauth1_p.h"
#include "qoauth1signature.h"
-#include "qoauthoobreplyhandler.h"
#include "qoauthhttpserverreplyhandler.h"
#include <QtCore/qmap.h>
diff --git a/src/oauth/qoauth1signature.cpp b/src/oauth/qoauth1signature.cpp
index 76a5a6a..0792b34 100644
--- a/src/oauth/qoauth1signature.cpp
+++ b/src/oauth/qoauth1signature.cpp
@@ -10,9 +10,6 @@
#include <QtNetwork/qnetworkaccessmanager.h>
-#include <functional>
-#include <type_traits>
-
QT_BEGIN_NAMESPACE
Q_LOGGING_CATEGORY(loggingCategory, "qt.networkauth.oauth1.signature")
diff --git a/src/oauth/qoauth2authorizationcodeflow.cpp b/src/oauth/qoauth2authorizationcodeflow.cpp
index 76d54a2..bf0ec82 100644
--- a/src/oauth/qoauth2authorizationcodeflow.cpp
+++ b/src/oauth/qoauth2authorizationcodeflow.cpp
@@ -15,6 +15,8 @@
#include <qauthenticator.h>
#include <qoauthhttpserverreplyhandler.h>
+#include <QtCore/qcryptographichash.h>
+
#include <functional>
QT_BEGIN_NAMESPACE
@@ -182,6 +184,44 @@ void QOAuth2AuthorizationCodeFlowPrivate::_q_authenticate(QNetworkReply *reply,
}
}
+/*
+ Creates and returns a new PKCE 'code_challenge', and stores the
+ underlying 'code_verifier' that was used to compute it.
+
+ The PKCE flow involves two parts:
+ 1. Authorization request: include the 'code_challenge' which
+ is computed from the 'code_verifier'.
+ 2. Access token request: include the original 'code_verifier'.
+
+ With these two parts the authorization server is able to verify
+ that the token request came from same entity as the original
+ authorization request, mitigating the risk of authorization code
+ interception attacks.
+*/
+QByteArray QOAuth2AuthorizationCodeFlowPrivate::createPKCEChallenge()
+{
+ switch (pkceMethod) {
+ case QOAuth2AuthorizationCodeFlow::PkceMethod::None:
+ pkceCodeVerifier.clear();
+ return {};
+ case QOAuth2AuthorizationCodeFlow::PkceMethod::Plain:
+ // RFC 7636 4.2, plain
+ // code_challenge = code_verifier
+ pkceCodeVerifier = generateRandomString(pkceVerifierLength);
+ return pkceCodeVerifier;
+ case QOAuth2AuthorizationCodeFlow::PkceMethod::S256:
+ // RFC 7636 4.2, S256
+ // code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
+ pkceCodeVerifier = generateRandomString(pkceVerifierLength);
+ // RFC 7636 3. Terminology:
+ // "with all trailing '=' characters omitted"
+ return QCryptographicHash::hash(pkceCodeVerifier, QCryptographicHash::Algorithm::Sha256)
+ .toBase64(QByteArray::Base64Option::Base64UrlEncoding
+ | QByteArray::Base64Option::OmitTrailingEquals);
+ };
+ Q_UNREACHABLE_RETURN({});
+}
+
/*!
Constructs a QOAuth2AuthorizationCodeFlow object with parent
object \a parent.
@@ -277,6 +317,75 @@ void QOAuth2AuthorizationCodeFlow::setAccessTokenUrl(const QUrl &accessTokenUrl)
}
/*!
+ \enum QOAuth2AuthorizationCodeFlow::PkceMethod
+ \since 6.8
+
+ List of available \l {https://datatracker.ietf.org/doc/html/rfc7636}
+ {Proof Key for Code Exchange (PKCE) methods}.
+
+ PKCE is a security measure to mitigate the risk of \l
+ {https://datatracker.ietf.org/doc/html/rfc7636#section-1}{authorization
+ code interception attacks}. As such it is relevant for OAuth2
+ "Authorization Code" flow (grant) and in particular with
+ native applications.
+
+ PKCE inserts additional parameters into authorization
+ and access token requests. With the help of these parameters the
+ authorization server is able to verify that an access token request
+ originates from the same entity that issued the authorization
+ request.
+
+ \value None PKCE is not used.
+ \value Plain The Plain PKCE method is used. Use this only if it is not
+ possible to use S256. With Plain method the
+ \l {https://datatracker.ietf.org/doc/html/rfc7636#section-4.2}{code challenge}
+ equals to the
+ \l {https://datatracker.ietf.org/doc/html/rfc7636#section-4.1}{code verifier}.
+ \value S256 The S256 PKCE method is used. This is the default and the
+ recommended method for native applications. With the S256 method
+ the \e {code challenge} is a base64url-encoded value of the
+ SHA-256 of the \e {code verifier}.
+
+ \sa setPkceMethod(), pkceMethod()
+*/
+
+/*!
+ \since 6.8
+
+ Sets the current PKCE method to \a method.
+
+ Optionally, the \a length parameter can be used to set the length
+ of the \c code_verifier. The value must be between 43 and 128 bytes.
+ The 'code verifier' itself is random-generated by the library.
+
+ \sa pkceMethod(), QOAuth2AuthorizationCodeFlow::PkceMethod
+*/
+void QOAuth2AuthorizationCodeFlow::setPkceMethod(PkceMethod method, quint8 length)
+{
+ Q_D(QOAuth2AuthorizationCodeFlow);
+ if (length < 43 || length > 128) {
+ // RFC 7636 Section 4.1, the code_verifer should be 43..128 bytes
+ qWarning("Invalid PKCE length provided, must be between 43..128. Ignoring.");
+ return;
+ }
+ d->pkceVerifierLength = length;
+ d->pkceMethod = method;
+}
+
+/*!
+ \since 6.8
+
+ Returns the current PKCE method.
+
+ \sa setPkceMethod(), QOAuth2AuthorizationCodeFlow::PkceMethod
+*/
+QOAuth2AuthorizationCodeFlow::PkceMethod QOAuth2AuthorizationCodeFlow::pkceMethod() const noexcept
+{
+ Q_D(const QOAuth2AuthorizationCodeFlow);
+ return d->pkceMethod;
+}
+
+/*!
Starts the authentication flow as described in
\l {https://tools.ietf.org/html/rfc6749#section-4.1}{The OAuth
2.0 Authorization Framework}
@@ -334,7 +443,6 @@ void QOAuth2AuthorizationCodeFlow::refreshAccessToken()
QUrlQuery query;
parameters.insert(Key::grantType, QStringLiteral("refresh_token"));
parameters.insert(Key::refreshToken, d->refreshToken);
- parameters.insert(Key::redirectUri, QUrl::toPercentEncoding(callback()));
parameters.insert(Key::clientIdentifier, d->clientIdentifier);
parameters.insert(Key::clientSharedSecret, d->clientIdentifierSharedKey);
if (d->modifyParametersFunction)
@@ -387,6 +495,11 @@ QUrl QOAuth2AuthorizationCodeFlow::buildAuthenticateUrl(const QMultiMap<QString,
p.insert(Key::redirectUri, callback());
p.insert(Key::scope, d->scope);
p.insert(Key::state, state);
+ if (d->pkceMethod != PkceMethod::None) {
+ p.insert(Key::codeChallenge, d->createPKCEChallenge());
+ p.insert(Key::codeChallengeMethod,
+ d->pkceMethod == PkceMethod::Plain ? u"plain"_s : u"S256"_s);
+ }
if (d->modifyParametersFunction)
d->modifyParametersFunction(Stage::RequestingAuthorization, &p);
url.setQuery(d->createQuery(p));
@@ -423,6 +536,9 @@ void QOAuth2AuthorizationCodeFlow::requestAccessToken(const QString &code)
parameters.insert(Key::redirectUri, QUrl::toPercentEncoding(callback()));
parameters.insert(Key::clientIdentifier, QUrl::toPercentEncoding(d->clientIdentifier));
+
+ if (d->pkceMethod != PkceMethod::None)
+ parameters.insert(Key::codeVerifier, d->pkceCodeVerifier);
if (!d->clientIdentifierSharedKey.isEmpty())
parameters.insert(Key::clientSharedSecret, d->clientIdentifierSharedKey);
if (d->modifyParametersFunction)
diff --git a/src/oauth/qoauth2authorizationcodeflow.h b/src/oauth/qoauth2authorizationcodeflow.h
index 48b53d0..7fbb18f 100644
--- a/src/oauth/qoauth2authorizationcodeflow.h
+++ b/src/oauth/qoauth2authorizationcodeflow.h
@@ -23,6 +23,7 @@ class Q_OAUTH_EXPORT QOAuth2AuthorizationCodeFlow : public QAbstractOAuth2
READ accessTokenUrl
WRITE setAccessTokenUrl
NOTIFY accessTokenUrlChanged)
+ Q_CLASSINFO("RegisterEnumClassesUnscoped", "false")
public:
explicit QOAuth2AuthorizationCodeFlow(QObject *parent = nullptr);
@@ -49,6 +50,16 @@ public:
QUrl accessTokenUrl() const;
void setAccessTokenUrl(const QUrl &accessTokenUrl);
+ enum class PkceMethod : quint8 {
+ S256,
+ Plain,
+ None = 255,
+ };
+ Q_ENUM(PkceMethod)
+
+ void setPkceMethod(PkceMethod method, quint8 length = 43) ;
+ PkceMethod pkceMethod() const noexcept;
+
public Q_SLOTS:
void grant() override;
void refreshAccessToken();
diff --git a/src/oauth/qoauth2authorizationcodeflow_p.h b/src/oauth/qoauth2authorizationcodeflow_p.h
index ab90ddf..2b8e0c7 100644
--- a/src/oauth/qoauth2authorizationcodeflow_p.h
+++ b/src/oauth/qoauth2authorizationcodeflow_p.h
@@ -43,6 +43,13 @@ public:
void _q_accessTokenRequestFailed(QAbstractOAuth::Error error, const QString &errorString = {});
void _q_authenticate(QNetworkReply *reply, QAuthenticator *authenticator);
+ QByteArray createPKCEChallenge();
+
+ QOAuth2AuthorizationCodeFlow::PkceMethod pkceMethod
+ = QOAuth2AuthorizationCodeFlow::PkceMethod::S256;
+ quint8 pkceVerifierLength = 43; // RFC 7636 Section 4.1
+ QByteArray pkceCodeVerifier;
+
QUrl accessTokenUrl;
QString tokenType;
QPointer<QNetworkReply> currentReply;
diff --git a/src/oauth/qoauthhttpserverreplyhandler.cpp b/src/oauth/qoauthhttpserverreplyhandler.cpp
index 7a63e5f..8fd2a9c 100644
--- a/src/oauth/qoauthhttpserverreplyhandler.cpp
+++ b/src/oauth/qoauthhttpserverreplyhandler.cpp
@@ -15,19 +15,66 @@
#include <QtCore/qurlquery.h>
#include <QtCore/qcoreapplication.h>
#include <QtCore/qloggingcategory.h>
+#include <QtCore/private/qlocale_p.h>
#include <QtNetwork/qtcpsocket.h>
#include <QtNetwork/qnetworkreply.h>
-#include <cctype>
-#include <cstring>
-#include <functional>
-
QT_BEGIN_NAMESPACE
+using namespace Qt::StringLiterals;
+
+/*!
+ \class QOAuthHttpServerReplyHandler
+ \inmodule QtNetworkAuth
+ \ingroup oauth
+ \since 5.8
+
+ \brief Handles loopback redirects by setting up a local HTTP server.
+
+ This class serves as a reply handler for
+ \l {https://datatracker.ietf.org/doc/html/rfc6749}{OAuth 2.0} authorization
+ processes that use
+ \l {https://datatracker.ietf.org/doc/html/rfc8252#section-7.3}{loopback redirection}.
+
+ The \l {https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2}
+ {redirect URI} is where the authorization server redirects the
+ user-agent (typically, and preferably, the system browser) once
+ the authorization part of the flow is complete. Loopback redirect
+ URIs use \c http as the scheme and either \e localhost or an IP
+ address literal as the host (see \l {IPv4 and IPv6}).
+
+ QOAuthHttpServerReplyHandler sets up a localhost server. Once the
+ authorization server redirects the browser to this localhost address,
+ the reply handler parses the redirection URI query parameters,
+ and then signals authorization completion with
+ \l {QAbstractOAuthReplyHandler::callbackReceived}{a signal}.
+
+ To handle other redirect URI schemes, see QOAuthUriSchemeReplyHandler.
+
+ The following code illustrates the usage. First, the needed variables:
+
+ \snippet src_oauth_replyhandlers.cpp httpserver-variables
+
+ Followed up by the OAuth setup (error handling omitted for brevity):
+
+ \snippet src_oauth_replyhandlers.cpp httpserver-oauth-setup
+
+ Finally, we then set up the URI scheme reply-handler:
+
+ \snippet src_oauth_replyhandlers.cpp httpserver-handler-setup
+
+ \section1 IPv4 and IPv6
+
+ Currently if the handler is a loopback address, IPv4 any address,
+ or IPv6 any address, the used callback is in the form of
+ \e {http://localhost:{port}/{path}}. Otherwise, for specific
+ IP addresses, the actual IP literal is used. For instance
+ \e {http://192.168.0.2:{port}/{path}} in the case of IPv4.
+*/
QOAuthHttpServerReplyHandlerPrivate::QOAuthHttpServerReplyHandlerPrivate(
QOAuthHttpServerReplyHandler *p) :
- text(QObject::tr("Callback received. Feel free to close this page.")), q_ptr(p)
+ text(QObject::tr("Callback received. Feel free to close this page.")), path(u'/'), q_ptr(p)
{
QObject::connect(&httpServer, &QTcpServer::newConnection, q_ptr,
[this]() { _q_clientConnected(); });
@@ -39,6 +86,25 @@ QOAuthHttpServerReplyHandlerPrivate::~QOAuthHttpServerReplyHandlerPrivate()
httpServer.close();
}
+QString QOAuthHttpServerReplyHandlerPrivate::callback() const
+{
+ QUrl url;
+ url.setScheme(u"http"_s);
+ url.setPort(callbackPort);
+ url.setPath(path);
+
+ // convert Any and Localhost addresses to "localhost"
+ if (callbackAddress.isLoopback() || callbackAddress == QHostAddress::AnyIPv4
+ || callbackAddress == QHostAddress::Any || callbackAddress == QHostAddress::AnyIPv6) {
+ url.setHost(u"localhost"_s);
+ } else {
+ url.setHost(callbackAddress.toString());
+ }
+
+ return url.toString(QUrl::EncodeSpaces | QUrl::EncodeUnicode | QUrl::EncodeDelimiters
+ | QUrl::EncodeReserved);
+}
+
void QOAuthHttpServerReplyHandlerPrivate::_q_clientConnected()
{
QTcpSocket *socket = httpServer.nextPendingConnection();
@@ -50,10 +116,14 @@ void QOAuthHttpServerReplyHandlerPrivate::_q_clientConnected()
void QOAuthHttpServerReplyHandlerPrivate::_q_readData(QTcpSocket *socket)
{
- if (!clients.contains(socket))
- clients[socket].port = httpServer.serverPort();
+ QHttpRequest *request = nullptr;
+ if (auto it = clients.find(socket); it == clients.end()) {
+ request = &clients[socket]; // insert it
+ request->port = httpServer.serverPort();
+ } else {
+ request = &*it;
+ }
- QHttpRequest *request = &clients[socket];
bool error = false;
if (Q_LIKELY(request->state == QHttpRequest::State::ReadingMethod))
@@ -85,7 +155,7 @@ void QOAuthHttpServerReplyHandlerPrivate::_q_readData(QTcpSocket *socket)
void QOAuthHttpServerReplyHandlerPrivate::_q_answerClient(QTcpSocket *socket, const QUrl &url)
{
Q_Q(QOAuthHttpServerReplyHandler);
- if (!url.path().startsWith(QLatin1String("/") + path)) {
+ if (url.path() != path) {
qCWarning(lcReplyHandler, "Invalid request: %s", qPrintable(url.toString()));
} else {
QVariantMap receivedData;
@@ -153,21 +223,18 @@ bool QOAuthHttpServerReplyHandlerPrivate::QHttpRequest::readUrl(QTcpSocket *sock
while (socket->bytesAvailable() && !finished) {
char c;
socket->getChar(&c);
- if (std::isspace(c))
+ if (ascii_isspace(c))
finished = true;
else
fragment += c;
}
if (finished) {
- if (!fragment.startsWith("/")) {
- qCWarning(lcReplyHandler, "Invalid URL path %s", fragment.constData());
- return false;
- }
- url.setUrl(QStringLiteral("http://127.0.0.1:") + QString::number(port) +
- QString::fromUtf8(fragment));
+ url = QUrl::fromEncoded(fragment);
state = State::ReadingStatus;
- if (!url.isValid()) {
- qCWarning(lcReplyHandler, "Invalid URL %s", fragment.constData());
+
+ if (!fragment.startsWith(u'/') || !url.isValid() || !url.scheme().isNull()
+ || !url.host().isNull()) {
+ qCWarning(lcReplyHandler, "Invalid request: %s", fragment.constData());
return false;
}
fragment.clear();
@@ -229,14 +296,34 @@ bool QOAuthHttpServerReplyHandlerPrivate::QHttpRequest::readHeader(QTcpSocket *s
return false;
}
+/*!
+ Constructs a QOAuthHttpServerReplyHandler object using \a parent as a
+ parent object. Calls \l {listen()} with port \c 0 and address
+ \l {QHostAddress::SpecialAddress}{Null}.
+
+ \sa listen()
+*/
QOAuthHttpServerReplyHandler::QOAuthHttpServerReplyHandler(QObject *parent) :
- QOAuthHttpServerReplyHandler(QHostAddress::Any, 0, parent)
+ QOAuthHttpServerReplyHandler(QHostAddress::Null, 0, parent)
{}
+/*!
+ Constructs a QOAuthHttpServerReplyHandler object using \a parent as a
+ parent object. Calls \l {listen()} with \a port and address
+ \l {QHostAddress::SpecialAddress}{Null}.
+
+ \sa listen()
+*/
QOAuthHttpServerReplyHandler::QOAuthHttpServerReplyHandler(quint16 port, QObject *parent) :
- QOAuthHttpServerReplyHandler(QHostAddress::Any, port, parent)
+ QOAuthHttpServerReplyHandler(QHostAddress::Null, port, parent)
{}
+/*!
+ Constructs a QOAuthHttpServerReplyHandler object using \a parent as a
+ parent object. Calls \l {listen()} with \a address and \a port.
+
+ \sa listen()
+*/
QOAuthHttpServerReplyHandler::QOAuthHttpServerReplyHandler(const QHostAddress &address,
quint16 port, QObject *parent) :
QOAuthOobReplyHandler(parent),
@@ -245,66 +332,158 @@ QOAuthHttpServerReplyHandler::QOAuthHttpServerReplyHandler(const QHostAddress &a
listen(address, port);
}
+/*!
+ Destroys the QOAuthHttpServerReplyHandler object.
+ Stops listening for connections / redirections.
+
+ \sa close()
+*/
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();
}
+/*!
+ Returns the path that is used as the path component of the
+ \l callback() / \l{https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2}
+ {OAuth2 redirect_uri parameter}.
+
+ \sa setCallbackPath()
+*/
QString QOAuthHttpServerReplyHandler::callbackPath() const
{
Q_D(const QOAuthHttpServerReplyHandler);
return d->path;
}
+/*!
+ Sets \a path to be used as the path component of the
+ \l callback().
+
+ \sa callbackPath()
+*/
void QOAuthHttpServerReplyHandler::setCallbackPath(const QString &path)
{
Q_D(QOAuthHttpServerReplyHandler);
+ // pass through QUrl to ensure normalization
+ QUrl url;
+ url.setPath(path);
+ d->path = url.path(QUrl::FullyEncoded);
+ if (d->path.isEmpty())
+ d->path = u'/';
+}
- QString copy = path;
- while (copy.startsWith(QLatin1Char('/')))
- copy = copy.mid(1);
+/*!
+ Returns the text that is used in response to the
+ redirection at the end of the authorization stage.
- d->path = copy;
-}
+ The text is wrapped in a simple HTML page, and displayed to
+ the user by the browser / user-agent which did the redirection.
+ The default text is
+ \badcode
+ Callback received. Feel free to close this page.
+ \endcode
+
+ \sa setCallbackText()
+*/
QString QOAuthHttpServerReplyHandler::callbackText() const
{
Q_D(const QOAuthHttpServerReplyHandler);
return d->text;
}
+/*!
+ Sets \a text to be used in response to the
+ redirection at the end of the authorization stage.
+
+ \sa callbackText()
+*/
void QOAuthHttpServerReplyHandler::setCallbackText(const QString &text)
{
Q_D(QOAuthHttpServerReplyHandler);
d->text = text;
}
+/*!
+ Returns the port on which this handler is listening,
+ otherwise returns 0.
+
+ \sa listen(), isListening()
+*/
quint16 QOAuthHttpServerReplyHandler::port() const
{
Q_D(const QOAuthHttpServerReplyHandler);
return d->httpServer.serverPort();
}
+/*!
+ Tells this handler to listen for incoming connections / redirections
+ on \a address and \a port. Returns \c true if listening is successful,
+ and \c false otherwise.
+
+ Active listening is only required when performing the initial
+ authorization phase, typically initiated by a
+ QOAuth2AuthorizationCodeFlow::grant() call.
+
+ It is recommended to close the listener after successful authorization.
+ Listening is not needed for
+ \l {QOAuth2AuthorizationCodeFlow::requestAccessToken()}{requesting access tokens}
+ or refreshing them.
+
+ If this function is called with \l {QHostAddress::SpecialAddress}{Null}
+ as the \a address, the handler will attempt to listen to
+ \l {QHostAddress::SpecialAddress}{LocalHost}, and if that fails,
+ \l {QHostAddress::SpecialAddress}{LocalHostIPv6}.
+
+ See also \l {IPv4 and IPv6}.
+
+ \sa close(), isListening(), QTcpServer::listen()
+*/
bool QOAuthHttpServerReplyHandler::listen(const QHostAddress &address, quint16 port)
{
Q_D(QOAuthHttpServerReplyHandler);
- return d->httpServer.listen(address, port);
+ bool success = false;
+
+ if (address.isNull()) {
+ // try IPv4 first, for greatest compatibility
+ success = d->httpServer.listen(QHostAddress::LocalHost, port);
+ if (!success)
+ success = d->httpServer.listen(QHostAddress::LocalHostIPv6, port);
+ }
+ if (!success)
+ success = d->httpServer.listen(address, port);
+
+ if (success) {
+ // Callback ('redirect_uri') value may be needed after this handler is closed
+ d->callbackAddress = d->httpServer.serverAddress();
+ d->callbackPort = d->httpServer.serverPort();
+ }
+
+ return success;
}
+/*!
+ Tells this handler to stop listening for connections / redirections.
+
+ \sa listen()
+*/
void QOAuthHttpServerReplyHandler::close()
{
Q_D(QOAuthHttpServerReplyHandler);
return d->httpServer.close();
}
+/*!
+ Returns \c true if this handler is currently listening,
+ and \c false otherwise.
+
+ \sa listen(), close()
+*/
bool QOAuthHttpServerReplyHandler::isListening() const
{
Q_D(const QOAuthHttpServerReplyHandler);
diff --git a/src/oauth/qoauthhttpserverreplyhandler_p.h b/src/oauth/qoauthhttpserverreplyhandler_p.h
index c7c9323..391516a 100644
--- a/src/oauth/qoauthhttpserverreplyhandler_p.h
+++ b/src/oauth/qoauthhttpserverreplyhandler_p.h
@@ -22,6 +22,7 @@
#include <private/qobject_p.h>
+#include <QtNetwork/qhostaddress.h>
#include <QtNetwork/qtcpserver.h>
QT_BEGIN_NAMESPACE
@@ -34,10 +35,13 @@ public:
explicit QOAuthHttpServerReplyHandlerPrivate(QOAuthHttpServerReplyHandler *p);
~QOAuthHttpServerReplyHandlerPrivate();
+ QString callback() const;
+
QTcpServer httpServer;
QString text;
- QHostAddress listenAddress = QHostAddress::LocalHost;
QString path;
+ QHostAddress callbackAddress;
+ quint16 callbackPort = 0;
private:
void _q_clientConnected();
diff --git a/src/oauth/qoauthoobreplyhandler.cpp b/src/oauth/qoauthoobreplyhandler.cpp
index bba7ab5..88171dc 100644
--- a/src/oauth/qoauthoobreplyhandler.cpp
+++ b/src/oauth/qoauthoobreplyhandler.cpp
@@ -4,6 +4,7 @@
#ifndef QT_NO_HTTP
#include "qoauthoobreplyhandler.h"
+#include "qoauthoobreplyhandler_p.h"
#include "qabstractoauthreplyhandler_p.h"
#include <QtCore/qurlquery.h>
@@ -21,6 +22,11 @@ QOAuthOobReplyHandler::QOAuthOobReplyHandler(QObject *parent)
: QAbstractOAuthReplyHandler(parent)
{}
+/*! \internal */
+QOAuthOobReplyHandler::QOAuthOobReplyHandler(QOAuthOobReplyHandlerPrivate &d, QObject *parent)
+ : QAbstractOAuthReplyHandler(d, parent)
+{}
+
QString QOAuthOobReplyHandler::callback() const
{
return QStringLiteral("oob");
diff --git a/src/oauth/qoauthoobreplyhandler.h b/src/oauth/qoauthoobreplyhandler.h
index 5418199..45390ec 100644
--- a/src/oauth/qoauthoobreplyhandler.h
+++ b/src/oauth/qoauthoobreplyhandler.h
@@ -11,6 +11,7 @@
QT_BEGIN_NAMESPACE
+class QOAuthOobReplyHandlerPrivate;
class Q_OAUTH_EXPORT QOAuthOobReplyHandler : public QAbstractOAuthReplyHandler
{
Q_OBJECT
@@ -22,6 +23,7 @@ public:
protected:
void networkReplyFinished(QNetworkReply *reply) override;
+ explicit QOAuthOobReplyHandler(QOAuthOobReplyHandlerPrivate &, QObject *parent = nullptr);
private:
QVariantMap parseResponse(const QByteArray &response);
diff --git a/src/oauth/qoauthoobreplyhandler_p.h b/src/oauth/qoauthoobreplyhandler_p.h
new file mode 100644
index 0000000..046e346
--- /dev/null
+++ b/src/oauth/qoauthoobreplyhandler_p.h
@@ -0,0 +1,28 @@
+// Copyright (C) 2024 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
+
+//
+// W A R N I N G
+// -------------
+//
+// This file is not part of the Qt API. It exists for the convenience
+// of the Network Access API. This header file may change from
+// version to version without notice, or even be removed.
+//
+// We mean it.
+//
+
+#ifndef QOAUTHOOBREPLYHANDLER_P_H
+#define QOAUTHOOBREPLYHANDLER_P_H
+
+#include <private/qobject_p.h>
+
+QT_BEGIN_NAMESPACE
+
+class QOAuthOobReplyHandlerPrivate : public QObjectPrivate
+{
+};
+
+QT_END_NAMESPACE
+
+#endif // QOAUTHOOBREPLYHANDLER_P_H
diff --git a/src/oauth/qoauthurischemereplyhandler.cpp b/src/oauth/qoauthurischemereplyhandler.cpp
new file mode 100644
index 0000000..d693938
--- /dev/null
+++ b/src/oauth/qoauthurischemereplyhandler.cpp
@@ -0,0 +1,362 @@
+// Copyright (C) 2024 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
+
+#include "qabstractoauthreplyhandler_p.h" // for lcReplyHandler()
+#include "qoauthoobreplyhandler_p.h"
+#include "qoauthurischemereplyhandler.h"
+
+#include <QtGui/qdesktopservices.h>
+
+#include <private/qobject_p.h>
+
+#include <QtCore/qloggingcategory.h>
+#include <QtCore/qurlquery.h>
+
+QT_BEGIN_NAMESPACE
+
+/*!
+ \class QOAuthUriSchemeReplyHandler
+ \inmodule QtNetworkAuth
+ \ingroup oauth
+ \since 6.8
+
+ \brief Handles private/custom and https URI scheme redirects.
+
+ This class serves as a reply handler for
+ \l {https://datatracker.ietf.org/doc/html/rfc6749}{OAuth 2.0} authorization
+ processes that use private/custom or HTTPS URI schemes for redirection.
+ It manages the reception of the authorization redirection (also known as the
+ callback) and the subsequent acquisition of access tokens.
+
+ The \l {https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2}
+ {redirection URI} is where the authorization server redirects the
+ user-agent (typically, and preferably, the system browser) once
+ the authorization part of the flow is complete.
+
+ The use of specific URI schemes requires configuration at the
+ operating system level to associate the URI with
+ the correct application. The way to set up this association varies
+ between operating systems. See \l {Platform Support and Dependencies}.
+
+ This class complements QOAuthHttpServerReplyHandler,
+ which handles \c http schemes by setting up a localhost server.
+
+ The following code illustrates the usage. First, the needed variables:
+
+ \snippet src_oauth_replyhandlers.cpp uri-variables
+
+ Followed up by the OAuth setup (error handling omitted for brevity):
+
+ \snippet src_oauth_replyhandlers.cpp uri-oauth-setup
+
+ Finally, we then set up the URI scheme reply-handler:
+
+ \snippet src_oauth_replyhandlers.cpp uri-handler-setup
+
+ \section1 Private/Custom URI Schemes
+
+ Custom URI schemes typically use reverse-domain notation followed
+ by a path, or occasionally a host/host+path:
+ \badcode
+ // Example with path:
+ com.example.myapp:/oauth2/callback
+ // Example with host:
+ com.example.myapp://oauth2.callback
+ \endcode
+
+ \section1 HTTPS URI Scheme
+
+ With HTTPS URI schemes, the redirect URLs are regular https links:
+ \badcode
+ https://myapp.example.com/oauth2/callback
+ \endcode
+
+ These links are called
+ \l {https://developer.apple.com/ios/universal-links/}{Universal Links}
+ on iOS and
+ \l {https://developer.android.com/training/app-links}{App Links on Android}.
+
+ The use of https schemes is recommended as it provides additional security
+ by forcing application developers to prove ownership of the URLs used. This
+ proving is done by hosting an association file, which the operating system
+ will consult as part of its internal URL dispatching.
+
+ The content of this file associates the application and the used URLs.
+ The association files must be publicly accessible without any HTTP
+ redirects. In addition, the hosting site must have valid certificates
+ and, at least with Android, the file must be served as
+ \c application/json content-type (refer to your server's configuration
+ guide).
+
+ In addition, https links can provide some usability benefits:
+ \list
+ \li The https URL doubles as a regular https link. If the
+ user hasn't installed the application (since the URL wasn't handled
+ by any application), the https link may for example serve
+ instructions to do so.
+ \li The application selection dialogue to open the URL may be avoided,
+ and instead your application may be opened automatically
+ \endlist
+
+ The tradeoff is that this requires extra setup as you need to set up this
+ publicly-hosted association file.
+
+ \section1 Platform Support and Dependencies
+
+ Currently supported platforms are Android, iOS, and macOS.
+
+ URI scheme listening is based on QDesktopServices::setUrlHandler()
+ and QDesktopServices::unsetUrlHandler(). These are currently
+ provided by Qt::Gui module and therefore QtNetworkAuth module
+ depends on Qt::Gui. If QtNetworkAuth is built without Qt::Gui,
+ QOAuthUriSchemeReplyHandler will not be included.
+
+ \section2 Android
+
+ On \l {Qt for Android}{Android} the URI schemes require:
+ \list
+ \li Setting up
+ \l {https://doc.qt.io/qt-6/qdesktopservices.html#android}{intent-filters}
+ in the application manifest
+ \li Optionally, for automatic verification with https schemes,
+ hosting a site association file
+ \l {https://doc.qt.io/qt-6/qdesktopservices.html#android}{assetlinks.json}
+ \endlist
+
+ See also the
+ \l {https://doc.qt.io/qt-6/android-manifest-file-configuration.html}
+ {Qt Android Manifest File Configuration}.
+
+ \section2 iOS and macOS
+
+ On \l {Qt for iOS}{iOS} and \l {Qt for macOS}{macOS} the URI schemes require:
+ \list
+ \li Setting up site association
+ \l {https://doc.qt.io/qt-6/qdesktopservices.html#ios}{entitlement}
+ \li With https schemes, hosting a
+ \l {https://doc.qt.io/qt-6/qdesktopservices.html#ios}{site association file}
+ (\c apple-app-site-association)
+ \endlist
+
+ \section2 \l {Qt for Windows}{Windows}, \l {Qt for Linux/X11}{Linux}
+
+ Currently not supported.
+*/
+
+class QOAuthUriSchemeReplyHandlerPrivate : public QOAuthOobReplyHandlerPrivate
+{
+ Q_DECLARE_PUBLIC(QOAuthUriSchemeReplyHandler)
+
+public:
+ bool hasValidRedirectUrl() const
+ {
+ // RFC 6749 Section 3.1.2
+ return redirectUrl.isValid()
+ && !redirectUrl.scheme().isEmpty()
+ && redirectUrl.fragment().isEmpty();
+ }
+
+ void _q_handleRedirectUrl(const QUrl &url)
+ {
+ Q_Q(QOAuthUriSchemeReplyHandler);
+ // Remove the query parameters from comparison, and compare them manually (the parameters
+ // of interest like 'code' and 'state' are received as query parameters and comparison
+ // would always fail). Fragments are removed as some servers (eg. Reddit) seem to add some,
+ // possibly for some implementation consistency with other OAuth flows where fragments
+ // are actually used.
+ bool urlMatch = url.matches(redirectUrl, QUrl::RemoveQuery | QUrl::RemoveFragment);
+
+ const QUrlQuery responseQuery{url};
+ if (urlMatch) {
+ // Verify that query parameters that are part of redirect URL are present in redirection
+ const auto registeredItems = QUrlQuery{redirectUrl}.queryItems();
+ for (const auto &item: registeredItems) {
+ if (!responseQuery.hasQueryItem(item.first)
+ || responseQuery.queryItemValue(item.first) != item.second) {
+ urlMatch = false;
+ break;
+ }
+ }
+ }
+
+ if (!urlMatch) {
+ qCDebug(lcReplyHandler(), "Url ignored");
+ // The URLs received here might be unrelated. Further, in case of "https" scheme,
+ // the first request issued to the authorization server comes through here
+ // (if this handler is listening)
+ QDesktopServices::openUrl(url);
+ return;
+ }
+
+ qCDebug(lcReplyHandler(), "Url handled");
+
+ QVariantMap resultParameters;
+ const auto responseItems = responseQuery.queryItems(QUrl::FullyDecoded);
+ for (const auto &item : responseItems)
+ resultParameters.insert(item.first, item.second);
+
+ emit q->callbackReceived(resultParameters);
+ }
+
+public:
+ QUrl redirectUrl;
+ bool listening = false;
+};
+
+/*!
+ \fn QOAuthUriSchemeReplyHandler::QOAuthUriSchemeReplyHandler()
+
+ Constructs a QOAuthUriSchemeReplyHandler object with empty callback()/
+ redirectUrl() and no parent. The constructed object does not automatically
+ listen.
+*/
+
+/*!
+ Constructs a QOAuthUriSchemeReplyHandler object with \a parent and empty
+ callback()/redirectUrl(). The constructed object does not automatically listen.
+*/
+QOAuthUriSchemeReplyHandler::QOAuthUriSchemeReplyHandler(QObject *parent) :
+ QOAuthOobReplyHandler(*new QOAuthUriSchemeReplyHandlerPrivate(), parent)
+{
+}
+
+/*!
+ Constructs a QOAuthUriSchemeReplyHandler object and sets \a parent as the
+ parent object and \a redirectUrl as the redirect URL. The constructed
+ object attempts automatically to listen.
+
+ \sa redirectUrl(), setRedirectUrl(), listen(), isListening()
+*/
+QOAuthUriSchemeReplyHandler::QOAuthUriSchemeReplyHandler(const QUrl &redirectUrl, QObject *parent)
+ : QOAuthUriSchemeReplyHandler(parent)
+{
+ Q_D(QOAuthUriSchemeReplyHandler);
+ d->redirectUrl = redirectUrl;
+ listen();
+}
+
+/*!
+ Destroys the QOAuthUriSchemeReplyHandler object. Closes
+ this handler.
+
+ \sa close()
+*/
+QOAuthUriSchemeReplyHandler::~QOAuthUriSchemeReplyHandler()
+{
+ close();
+}
+
+QString QOAuthUriSchemeReplyHandler::callback() const
+{
+ Q_D(const QOAuthUriSchemeReplyHandler);
+ return d->redirectUrl.toString();
+}
+
+/*!
+ \property QOAuthUriSchemeReplyHandler::redirectUrl
+ \brief The URL used to receive authorization redirection/response.
+
+ This property is used as the
+ \l{https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2}
+ {OAuth2 redirect_uri parameter}, which is sent as part of the
+ authorization request. The \c redirect_uri is acquired by
+ calling QUrl::toString() with default options.
+
+ The URL must match the one registered at the authorization server,
+ as the authorization servers likely reject any mismatching redirect_uris.
+
+ Similarly, when this handler receives the redirection,
+ the redirection URL must match the URL set here. The handler
+ compares the scheme, host, port, path, and any
+ query items that were part of the URL set by this method.
+
+ The URL is handled only if all of these match. The comparison of query
+ parameters excludes any additional query parameters that may have been set
+ at server-side, as these contain the actual data of interest.
+*/
+void QOAuthUriSchemeReplyHandler::setRedirectUrl(const QUrl &url)
+{
+ Q_D(QOAuthUriSchemeReplyHandler);
+ if (url == d->redirectUrl)
+ return;
+
+ if (d->listening) {
+ close(); // close previous url listening first
+ d->redirectUrl = url;
+ listen();
+ } else {
+ d->redirectUrl = url;
+ }
+ emit redirectUrlChanged();
+}
+
+QUrl QOAuthUriSchemeReplyHandler::redirectUrl() const
+{
+ Q_D(const QOAuthUriSchemeReplyHandler);
+ return d->redirectUrl;
+}
+
+/*!
+ Tells this handler to listen for incoming URLs. Returns
+ \c true if listening is successful, and \c false otherwise.
+
+ The handler will match URLs to redirectUrl().
+ If the received URL does not match, it will be forwarded to
+ QDesktopServices::openURL().
+
+ Active listening is only required when performing the initial
+ authorization phase, typically initiated by a
+ QOAuth2AuthorizationCodeFlow::grant() call.
+
+ It is recommended to close the listener after successful authorization.
+ Listening is not needed for
+ \l {QOAuth2AuthorizationCodeFlow::requestAccessToken()}{acquiring access tokens}.
+*/
+bool QOAuthUriSchemeReplyHandler::listen()
+{
+ Q_D(QOAuthUriSchemeReplyHandler);
+ if (d->listening)
+ return true;
+
+ if (!d->hasValidRedirectUrl()) {
+ qCWarning(lcReplyHandler(), "listen(): callback url not valid");
+ return false;
+ }
+ qCDebug(lcReplyHandler(), "listen() URL listener");
+ QDesktopServices::setUrlHandler(d->redirectUrl.scheme(), this, "_q_handleRedirectUrl");
+
+ d->listening = true;
+ return true;
+}
+
+/*!
+ Tells this handler to stop listening for incoming URLs.
+
+ \sa listen(), isListening()
+*/
+void QOAuthUriSchemeReplyHandler::close()
+{
+ Q_D(QOAuthUriSchemeReplyHandler);
+ if (!d->listening)
+ return;
+
+ qCDebug(lcReplyHandler(), "close() URL listener");
+ QDesktopServices::unsetUrlHandler(d->redirectUrl.scheme());
+ d->listening = false;
+}
+
+/*!
+ Returns \c true if this handler is currently listening,
+ and \c false otherwise.
+
+ \sa listen(), close()
+*/
+bool QOAuthUriSchemeReplyHandler::isListening() const noexcept
+{
+ Q_D(const QOAuthUriSchemeReplyHandler);
+ return d->listening;
+}
+
+QT_END_NAMESPACE
+
+#include "moc_qoauthurischemereplyhandler.cpp"
diff --git a/src/oauth/qoauthurischemereplyhandler.h b/src/oauth/qoauthurischemereplyhandler.h
new file mode 100644
index 0000000..6cdbbb1
--- /dev/null
+++ b/src/oauth/qoauthurischemereplyhandler.h
@@ -0,0 +1,46 @@
+// Copyright (C) 2024 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
+
+#ifndef QOAUTHURISCHEMEREPLYHANDLER_H
+#define QOAUTHURISCHEMEREPLYHANDLER_H
+
+#include <QtNetworkAuth/qoauthglobal.h>
+#include <QtNetworkAuth/qoauthoobreplyhandler.h>
+
+#include <QtCore/qurl.h>
+
+QT_BEGIN_NAMESPACE
+
+class QOAuthUriSchemeReplyHandlerPrivate;
+class Q_OAUTH_EXPORT QOAuthUriSchemeReplyHandler : public QOAuthOobReplyHandler
+{
+ Q_OBJECT
+ Q_PROPERTY(QUrl redirectUrl READ redirectUrl WRITE setRedirectUrl NOTIFY redirectUrlChanged FINAL)
+public:
+ Q_IMPLICIT QOAuthUriSchemeReplyHandler() : QOAuthUriSchemeReplyHandler(nullptr) {}
+ explicit QOAuthUriSchemeReplyHandler(QObject *parent);
+ explicit QOAuthUriSchemeReplyHandler(const QUrl &redirectUrl, QObject *parent = nullptr);
+ ~QOAuthUriSchemeReplyHandler() override;
+
+ QString callback() const override;
+
+ void setRedirectUrl(const QUrl &url);
+ QUrl redirectUrl() const;
+
+ bool listen();
+ void close();
+ bool isListening() const noexcept;
+
+Q_SIGNALS:
+ void redirectUrlChanged();
+
+private:
+ Q_DISABLE_COPY(QOAuthUriSchemeReplyHandler)
+ Q_DECLARE_PRIVATE(QOAuthUriSchemeReplyHandler)
+ // Private slot for providing a callback slot for QDesktopServices::setUrlHandler
+ Q_PRIVATE_SLOT(d_func(), void _q_handleRedirectUrl(const QUrl &url))
+};
+
+QT_END_NAMESPACE
+
+#endif // QOAUTHURISCHEMEREPLYHANDLER_H
diff --git a/tests/auto/CMakeLists.txt b/tests/auto/CMakeLists.txt
index 6fd44c1..dee9ef6 100644
--- a/tests/auto/CMakeLists.txt
+++ b/tests/auto/CMakeLists.txt
@@ -6,6 +6,9 @@ add_subdirectory(oauth1)
add_subdirectory(oauth2)
add_subdirectory(oauth1signature)
add_subdirectory(oauthhttpserverreplyhandler)
+if(QT_FEATURE_urischeme_replyhandler)
+ add_subdirectory(oauthurischemereplyhandler)
+endif()
if(QT_FEATURE_private_tests)
add_subdirectory(abstractoauth)
endif()
diff --git a/tests/auto/oauth2/tst_oauth2.cpp b/tests/auto/oauth2/tst_oauth2.cpp
index 590ffac..dc2c6a9 100644
--- a/tests/auto/oauth2/tst_oauth2.cpp
+++ b/tests/auto/oauth2/tst_oauth2.cpp
@@ -10,6 +10,8 @@
#include <QtNetworkAuth/qabstractoauthreplyhandler.h>
#include <QtNetworkAuth/qoauth2authorizationcodeflow.h>
+#include <QtCore/qcryptographichash.h>
+
#include "webserver.h"
#include "tlswebserver.h"
@@ -27,6 +29,8 @@ private Q_SLOTS:
void tokenRequestErrors();
void authorizationErrors();
void prepareRequest();
+ void pkce_data();
+ void pkce();
#ifndef QT_NO_SSL
void setSslConfig();
void tlsAuthentication();
@@ -387,6 +391,89 @@ void tst_OAuth2::prepareRequest()
QCOMPARE(request.rawHeader("Authorization"), QByteArray("Bearer access_token"));
}
+using Method = QOAuth2AuthorizationCodeFlow::PkceMethod;
+
+void tst_OAuth2::pkce_data()
+{
+ QTest::addColumn<Method>("method");
+ QTest::addColumn<quint8>("verifierLength");
+
+ QTest::addRow("none") << Method::None << quint8(43);
+ QTest::addRow("plain_43") << Method::Plain << quint8(43);
+ QTest::addRow("plain_77") << Method::Plain << quint8(77);
+ QTest::addRow("S256_43") << Method::S256 << quint8(43);
+ QTest::addRow("S256_88") << Method::S256 << quint8(88);
+}
+
+void tst_OAuth2::pkce()
+{
+ QFETCH(Method, method);
+ QFETCH(quint8, verifierLength);
+
+ static constexpr auto code_verifier = "code_verifier"_L1;
+ static constexpr auto code_challenge = "code_challenge"_L1;
+ static constexpr auto code_challenge_method = "code_challenge_method"_L1;
+
+ QOAuth2AuthorizationCodeFlow oauth2;
+ oauth2.setAuthorizationUrl(QUrl("authorization_url"));
+ oauth2.setAccessTokenUrl(QUrl("access_token_url"));
+ oauth2.setState("a_state"_L1);
+ QCOMPARE(oauth2.pkceMethod(), Method::S256); // the default
+ oauth2.setPkceMethod(method, verifierLength);
+ QCOMPARE(oauth2.pkceMethod(), method);
+
+ QMultiMap<QString, QVariant> tokenRequestParms;
+ oauth2.setModifyParametersFunction(
+ [&] (QAbstractOAuth::Stage stage, QMultiMap<QString, QVariant> *parameters) {
+ if (stage == QAbstractOAuth::Stage::RequestingAccessToken)
+ tokenRequestParms = *parameters;
+ });
+
+ ReplyHandler replyHandler;
+ oauth2.setReplyHandler(&replyHandler);
+ QSignalSpy openBrowserSpy(&oauth2, &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser);
+
+ oauth2.grant(); // Initiate authorization
+
+ // 1. Verify the authorization URL query parameters
+ QTRY_VERIFY(!openBrowserSpy.isEmpty());
+ auto authParms = QUrlQuery{openBrowserSpy.takeFirst().at(0).toUrl()};
+ QVERIFY(!authParms.hasQueryItem(code_verifier));
+ const auto codeChallenge = authParms.queryItemValue(code_challenge).toLatin1();
+ if (method == Method::None) {
+ QVERIFY(!authParms.hasQueryItem(code_challenge));
+ QVERIFY(!authParms.hasQueryItem(code_challenge_method));
+ } else if (method == Method::Plain) {
+ QCOMPARE(codeChallenge.size(), verifierLength); // With plain the challenge == verifier
+ QCOMPARE(authParms.queryItemValue(code_challenge_method), "plain"_L1);
+ } else { // S256
+ QCOMPARE(codeChallenge.size(), 43); // SHA-256 is 32 bytes, and that in base64 is ~43 bytes
+ QCOMPARE(authParms.queryItemValue(code_challenge_method), "S256"_L1);
+ }
+
+ // Conclude authorization => starts access token request
+ emit replyHandler.callbackReceived({{"code", "acode"}, {"state", "a_state"}});
+
+ // 2. Verify the access token request parameters
+ QTRY_VERIFY(!tokenRequestParms.isEmpty());
+ QVERIFY(!tokenRequestParms.contains(code_challenge));
+ QVERIFY(!tokenRequestParms.contains(code_challenge_method));
+ // Verify the challenge received earlier was based on the verifier we receive here
+ if (method == Method::None) {
+ QVERIFY(!tokenRequestParms.contains(code_verifier));
+ } else if (method == Method::Plain) {
+ QVERIFY(tokenRequestParms.contains(code_verifier));
+ QCOMPARE(tokenRequestParms.value(code_verifier).toByteArray(), codeChallenge);
+ } else { // S256
+ QVERIFY(tokenRequestParms.contains(code_verifier));
+ const auto codeVerifier = tokenRequestParms.value(code_verifier).toByteArray();
+ QCOMPARE(codeVerifier.size(), verifierLength);
+ QCOMPARE(QCryptographicHash::hash(codeVerifier, QCryptographicHash::Algorithm::Sha256)
+ .toBase64(QByteArray::Base64Option::Base64UrlEncoding | QByteArray::Base64Option::OmitTrailingEquals)
+ , codeChallenge);
+ }
+}
+
#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 a5def79..1992bf7 100644
--- a/tests/auto/oauthhttpserverreplyhandler/tst_oauthhttpserverreplyhandler.cpp
+++ b/tests/auto/oauthhttpserverreplyhandler/tst_oauthhttpserverreplyhandler.cpp
@@ -9,16 +9,114 @@
typedef QSharedPointer<QNetworkReply> QNetworkReplyPtr;
+static constexpr std::chrono::seconds Timeout(20);
+
+using namespace Qt::StringLiterals;
+
class tst_QOAuthHttpServerReplyHandler : public QObject
{
Q_OBJECT
private Q_SLOTS:
+ void callback_data();
void callback();
+ void callbackCaching();
+ void callbackWithQuery();
+ void badCallbackUris_data();
+ void badCallbackUris();
+ void badCallbackWrongMethod();
};
+void tst_QOAuthHttpServerReplyHandler::callback_data()
+{
+ QTest::addColumn<QString>("callbackPath");
+ QTest::addColumn<QString>("uri");
+ QTest::addColumn<bool>("success");
+
+ QTest::newRow("default") << QString() << QString() << true;
+ QTest::newRow("empty") << "" << QString() << true;
+ QTest::newRow("ascii-path") << "/foobar" << QString() << true;
+ QTest::newRow("utf8-path") << "/áéíóú" << QString() << true;
+ QTest::newRow("questionmark") << "/?" << QString() << true;
+ QTest::newRow("hash") << "/#" << QString() << true;
+
+ QTest::newRow("default-fragment") << QString() << "/#shouldntsee" << true;
+ QTest::newRow("default-query") << QString() << "/?some=query" << true;
+
+ QTest::newRow("default-wrongpath") << QString() << "/foo" << false;
+ QTest::newRow("changed-wrongpath") << "/foo" << "/bar" << false;
+ QTest::newRow("changed-wrongpathprefix") << "/foo" << "/foobar" << false;
+ QTest::newRow("changed-wrongpathprefixpath") << "/foo" << "/foo/bar" << false;
+}
+
void tst_QOAuthHttpServerReplyHandler::callback()
{
+ QFETCH(QString, callbackPath);
+ QFETCH(QString, uri);
+ QFETCH(bool, success);
+
+ int count = 0;
+ QOAuthHttpServerReplyHandler replyHandler;
+ QVERIFY(replyHandler.isListening());
+ connect(&replyHandler, &QOAuthHttpServerReplyHandler::callbackReceived, this, [&](
+ const QVariantMap &) {
+ ++count;
+ QTestEventLoop::instance().exitLoop();
+ });
+
+ if (!callbackPath.isNull())
+ replyHandler.setCallbackPath(callbackPath);
+ QUrl callback(replyHandler.callback());
+ QVERIFY(!callback.isEmpty());
+
+ // maybe change the URL
+ callback = callback.resolved(QUrl(uri));
+
+ QNetworkAccessManager networkAccessManager;
+ QNetworkRequest request(callback);
+ QNetworkReplyPtr reply;
+ reply.reset(networkAccessManager.get(request));
+ connect(reply.get(), &QNetworkReply::finished, &QTestEventLoop::instance(),
+ &QTestEventLoop::exitLoop);
+
+ if (!success) {
+ QByteArray httpUri = callback.toEncoded(QUrl::RemoveScheme | QUrl::RemoveAuthority | QUrl::RemoveFragment);
+ QTest::ignoreMessage(QtWarningMsg, "Invalid request: " + httpUri);
+ QTest::ignoreMessage(QtWarningMsg, "Invalid request: " + httpUri);
+ }
+ QTestEventLoop::instance().enterLoop(Timeout);
+ QCOMPARE(count > 0, success);
+ QVERIFY(!QTestEventLoop::instance().timeout());
+}
+
+void tst_QOAuthHttpServerReplyHandler::callbackCaching()
+{
+ QOAuthHttpServerReplyHandler replyHandler;
+ constexpr auto callbackPath = "/foo"_L1;
+ constexpr auto callbackHost = "localhost"_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);
+}
+
+void tst_QOAuthHttpServerReplyHandler::callbackWithQuery()
+{
int count = 0;
QOAuthHttpServerReplyHandler replyHandler;
QUrlQuery query("callback=test");
@@ -26,7 +124,7 @@ void tst_QOAuthHttpServerReplyHandler::callback()
QUrl callback(replyHandler.callback());
QVERIFY(!callback.isEmpty());
callback.setQuery(query);
- QEventLoop eventLoop;
+
connect(&replyHandler, &QOAuthHttpServerReplyHandler::callbackReceived, this, [&](
const QVariantMap &parameters) {
for (auto item : query.queryItems()) {
@@ -34,7 +132,7 @@ void tst_QOAuthHttpServerReplyHandler::callback()
QCOMPARE(parameters[item.first].toString(), item.second);
}
count = parameters.size();
- eventLoop.quit();
+ QTestEventLoop::instance().exitLoop();
});
QNetworkAccessManager networkAccessManager;
@@ -42,8 +140,83 @@ void tst_QOAuthHttpServerReplyHandler::callback()
request.setUrl(callback);
QNetworkReplyPtr reply;
reply.reset(networkAccessManager.get(request));
- eventLoop.exec();
+ connect(reply.get(), &QNetworkReply::finished, &QTestEventLoop::instance(),
+ &QTestEventLoop::exitLoop);
+ QTestEventLoop::instance().enterLoop(Timeout);
QCOMPARE(count, query.queryItems().size());
+ QVERIFY(!QTestEventLoop::instance().timeout());
+}
+
+void tst_QOAuthHttpServerReplyHandler::badCallbackUris_data()
+{
+ QTest::addColumn<QString>("uri");
+
+ QTest::newRow("relative-path") << "foobar";
+ QTest::newRow("encoded-slash") << "%2F";
+ QTest::newRow("query") << "?some=query";
+ QTest::newRow("full-url") << "http://localhost/";
+ QTest::newRow("authority") << "//localhost";
+ // requires QUrl fix
+ //QTest::newRow("double-slash") << "//";
+ //QTest::newRow("triple-slash") << "///";
+}
+
+void tst_QOAuthHttpServerReplyHandler::badCallbackUris()
+{
+ QFETCH(QString, uri);
+
+ int count = 0;
+ QOAuthHttpServerReplyHandler replyHandler;
+ QVERIFY(replyHandler.isListening());
+ connect(&replyHandler, &QOAuthHttpServerReplyHandler::callbackReceived, this, [&](
+ const QVariantMap &) {
+ ++count;
+ QTestEventLoop::instance().exitLoop();
+ });
+ QUrl callback(replyHandler.callback());
+ QVERIFY(!callback.isEmpty());
+
+ QTcpSocket socket;
+ socket.connectToHost(QHostAddress::LocalHost, replyHandler.port());
+ socket.write("GET " + uri.toLocal8Bit() + " HTTP/1.0\r\n"
+ "Host: localhost\r\n"
+ "\r\n");
+ connect(&socket, &QTcpSocket::disconnected, &QTestEventLoop::instance(),
+ &QTestEventLoop::exitLoop);
+
+ QTest::ignoreMessage(QtWarningMsg, "Invalid request: " + uri.toLocal8Bit());
+ QTest::ignoreMessage(QtWarningMsg, "Invalid URL");
+
+ QTestEventLoop::instance().enterLoop(Timeout);
+ QCOMPARE(count, 0);
+ QVERIFY(!QTestEventLoop::instance().timeout());
+}
+
+void tst_QOAuthHttpServerReplyHandler::badCallbackWrongMethod()
+{
+ int count = 0;
+ QOAuthHttpServerReplyHandler replyHandler;
+ QVERIFY(replyHandler.isListening());
+ connect(&replyHandler, &QOAuthHttpServerReplyHandler::callbackReceived, this, [&](
+ const QVariantMap &) {
+ ++count;
+ QTestEventLoop::instance().exitLoop();
+ });
+ QUrl callback(replyHandler.callback());
+ QVERIFY(!callback.isEmpty());
+
+ QTcpSocket socket;
+ socket.connectToHost(QHostAddress::LocalHost, replyHandler.port());
+ socket.write("EHLO localhost\r\n");
+ connect(&socket, &QTcpSocket::disconnected, &QTestEventLoop::instance(),
+ &QTestEventLoop::exitLoop);
+
+ QTest::ignoreMessage(QtWarningMsg, "Invalid operation EHLO");
+ QTest::ignoreMessage(QtWarningMsg, "Invalid Method");
+
+ QTestEventLoop::instance().enterLoop(Timeout);
+ QCOMPARE(count, 0);
+ QVERIFY(!QTestEventLoop::instance().timeout());
}
QTEST_MAIN(tst_QOAuthHttpServerReplyHandler)
diff --git a/tests/auto/oauthurischemereplyhandler/CMakeLists.txt b/tests/auto/oauthurischemereplyhandler/CMakeLists.txt
new file mode 100644
index 0000000..546d236
--- /dev/null
+++ b/tests/auto/oauthurischemereplyhandler/CMakeLists.txt
@@ -0,0 +1,12 @@
+# Copyright (C) 2024 The Qt Company Ltd.
+# SPDX-License-Identifier: BSD-3-Clause
+
+qt_internal_add_test(tst_oauthurischemereplyhandler
+ SOURCES
+ tst_oauthurischemereplyhandler.cpp
+ LIBRARIES
+ Qt::Core
+ Qt::Gui
+ Qt::Network
+ Qt::NetworkAuth
+)
diff --git a/tests/auto/oauthurischemereplyhandler/tst_oauthurischemereplyhandler.cpp b/tests/auto/oauthurischemereplyhandler/tst_oauthurischemereplyhandler.cpp
new file mode 100644
index 0000000..6892ad3
--- /dev/null
+++ b/tests/auto/oauthurischemereplyhandler/tst_oauthurischemereplyhandler.cpp
@@ -0,0 +1,201 @@
+// Copyright (C) 2024 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
+
+#include <QtTest/qsignalspy.h>
+#include <QtTest/qtest.h>
+
+#include <QtGui/qdesktopservices.h>
+
+#include <QtNetworkAuth/qoauth2authorizationcodeflow.h>
+#include <QtNetworkAuth/qoauthurischemereplyhandler.h>
+
+#include <QtCore/qloggingcategory.h>
+#include <QtCore/qscopeguard.h>
+#include <QtCore/qurl.h>
+#include <QtCore/qurlquery.h>
+
+using namespace Qt::StringLiterals;
+
+class tst_QOAuthUriSchemeReplyHandler : public QObject
+{
+ Q_OBJECT
+
+private Q_SLOTS:
+ void construction();
+ void redirectUrl();
+ void listenClose();
+ void authorization_data();
+ void authorization();
+
+private:
+ const QUrl customUrlWithPath{"com.my.app:/somepath"_L1};
+ const QUrl customUrlWithoutPath{"com.my.app"_L1};
+ const QUrl customUrlWithHost{"com.my.app://some.host.org"_L1};
+ const QUrl customUrlWithExtra{"com.my.app:/somepath:1234?key=value"_L1};
+ const QUrl authorizationUrl{"https://example.foo.bar.com/api/authorize"_L1};
+ const QUrl accessTokenUrl{"idontexist"_L1}; // token acqusition is irrelevant for this test
+ static constexpr auto state = "a_state"_L1;
+ static constexpr auto code = "a_code"_L1;
+ const QString stateCodeQuery = "?state="_L1 + state + "&code="_L1 + code;
+ const QVariantMap stateCodeMap{{"state"_L1, state}, {"code"_L1, code}};
+};
+
+void tst_QOAuthUriSchemeReplyHandler::construction()
+{
+ QOAuthUriSchemeReplyHandler rh1;
+ QVERIFY(!rh1.isListening());
+ QVERIFY(rh1.callback().isEmpty());
+ QVERIFY(rh1.redirectUrl().isEmpty());
+
+ QOAuthUriSchemeReplyHandler rh2(customUrlWithPath);
+ QVERIFY(rh2.isListening());
+ QCOMPARE(rh2.redirectUrl(), customUrlWithPath);
+ QCOMPARE(rh2.callback(), customUrlWithPath.toString());
+}
+
+void tst_QOAuthUriSchemeReplyHandler::redirectUrl()
+{
+ QOAuthUriSchemeReplyHandler rh;
+ QSignalSpy urlChangedSpy(&rh, &QOAuthUriSchemeReplyHandler::redirectUrlChanged);
+
+ rh.setRedirectUrl(customUrlWithPath);
+ QCOMPARE(rh.redirectUrl(), customUrlWithPath);
+ QCOMPARE(rh.callback(), customUrlWithPath.toString());
+ QCOMPARE(urlChangedSpy.size(), 1);
+
+ rh.setRedirectUrl(customUrlWithHost);
+ QCOMPARE(rh.redirectUrl(), customUrlWithHost);
+ QCOMPARE(rh.callback(), customUrlWithHost.toString());
+ QCOMPARE(urlChangedSpy.size(), 2);
+
+ rh.setRedirectUrl(customUrlWithExtra);
+ QCOMPARE(rh.redirectUrl(), customUrlWithExtra);
+ QCOMPARE(rh.callback(), customUrlWithExtra.toString());
+ QCOMPARE(urlChangedSpy.size(), 3);
+
+ rh.setRedirectUrl(customUrlWithExtra); // Same URL again
+ QCOMPARE(urlChangedSpy.size(), 3);
+
+ rh.setRedirectUrl({});
+ QVERIFY(rh.redirectUrl().isEmpty());
+ QVERIFY(rh.callback().isEmpty());
+ QCOMPARE(urlChangedSpy.size(), 4);
+}
+
+void tst_QOAuthUriSchemeReplyHandler::listenClose()
+{
+ const QUrl scheme1 = u"scheme1:/foo"_s;
+ const QUrl scheme2 = u"scheme2:/foo"_s;
+ QOAuthUriSchemeReplyHandler rh;
+ QSignalSpy callbackSpy(&rh, &QAbstractOAuthReplyHandler::callbackReceived);
+
+ rh.setRedirectUrl(scheme1);
+ QVERIFY(rh.listen());
+ QDesktopServices::openUrl(scheme1);
+ QCOMPARE(callbackSpy.size(), 1);
+
+ rh.setRedirectUrl(scheme2);
+ QDesktopServices::openUrl(scheme2);
+ QCOMPARE(callbackSpy.size(), 2);
+
+ QDesktopServices::openUrl(scheme1); // Previous scheme should be unregistered
+ QCOMPARE(callbackSpy.size(), 2);
+
+ rh.close();
+ QDesktopServices::openUrl(scheme2);
+ QCOMPARE(callbackSpy.size(), 2);
+}
+
+void tst_QOAuthUriSchemeReplyHandler::authorization_data()
+{
+ QTest::addColumn<QUrl>("registered_redirect_uri");
+ QTest::addColumn<QUrl>("response_redirect_uri");
+ QTest::addColumn<bool>("matches");
+ QTest::addColumn<QVariantMap>("result_parameters");
+
+ QTest::newRow("match_with_path")
+ << QUrl{"com.example:/cb"_L1} << QUrl{"com.example:/cb"_L1 + stateCodeQuery}
+ << true << stateCodeMap;
+
+ QTest::newRow("match_with_host")
+ << QUrl{"com.example://cb.example.org"_L1}
+ << QUrl{"com.example://cb.example.org"_L1 + stateCodeQuery}
+ << true << stateCodeMap;
+
+ QTest::newRow("match_with_host_and_path")
+ << QUrl{"com.example://cb.example.org/a_path"_L1}
+ << QUrl{"com.example://cb.example.org/a_path"_L1 + stateCodeQuery}
+ << true << stateCodeMap;
+
+ QVariantMap resultParameters = stateCodeMap;
+ resultParameters.insert("lang"_L1, "de");
+ QTest::newRow("match_with_path_and_query")
+ << QUrl{"com.example:/cb?lang=de"_L1}
+ << QUrl{"com.example:/cb"_L1 + stateCodeQuery + "&lang=de"_L1}
+ << true << resultParameters;
+
+ QTest::newRow("mismatch_path")
+ << QUrl{"com.example:/cb"_L1} << QUrl{"com.example:/wrong"_L1 + stateCodeQuery}
+ << false << stateCodeMap;
+
+ QTest::newRow("mismatch_parameters")
+ << QUrl{"com.example:/cb?lang=de"_L1} << QUrl{"com.example:/cb?code=foo"_L1}
+ << false << stateCodeMap;
+
+ QTest::newRow("mismatch_parameter_value")
+ << QUrl{"com.example:/cb?lang=de"_L1} << QUrl{"com.example:/cb?lang=wrong"_L1}
+ << false << stateCodeMap;
+}
+
+void tst_QOAuthUriSchemeReplyHandler::authorization()
+{
+ // The registered redirect URI is what is typically registered at the cloud
+ QFETCH(const QUrl, registered_redirect_uri);
+ // The response redirect URI is registered URI with additional parameters from server
+ QFETCH(const QUrl, response_redirect_uri);
+ QFETCH(const bool, matches);
+ QFETCH(const QVariantMap, result_parameters);
+
+ QOAuthUriSchemeReplyHandler rh;
+ rh.setRedirectUrl(registered_redirect_uri);
+ rh.listen();
+
+ QOAuth2AuthorizationCodeFlow oauth;
+ oauth.setAuthorizationUrl(authorizationUrl);
+ oauth.setAccessTokenUrl(accessTokenUrl);
+ oauth.setState(state);
+ oauth.setReplyHandler(&rh);
+
+ QSignalSpy openBrowserSpy(&oauth, &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser);
+ QSignalSpy redirectedSpy(&rh, &QAbstractOAuthReplyHandler::callbackReceived);
+
+ oauth.grant();
+
+ // First step would be to open browser: catch the signal and verify correct redirect_uri
+ QTRY_VERIFY(!openBrowserSpy.isEmpty());
+ const auto authParms = QUrlQuery{openBrowserSpy.takeFirst().at(0).toUrl()};
+ QVERIFY(authParms.hasQueryItem(u"redirect_uri"_s));
+ QCOMPARE(authParms.queryItemValue(u"redirect_uri"_s), registered_redirect_uri.toString());
+
+ // The failure is silent from API point of view (consider user just closing the browser, the
+ // application would never know) => use log messages
+ auto cleanup = qScopeGuard([]{
+ QLoggingCategory::setFilterRules(u"qt.networkauth.replyhandler=false"_s);
+ });
+ QLoggingCategory::setFilterRules(u"qt.networkauth.replyhandler=true"_s);
+ QRegularExpression re;
+ if (matches)
+ re.setPattern("Url handled*"_L1);
+ else
+ re.setPattern("Url ignored*"_L1);
+ // Don't open the browser but mimic authorization completion by invoking the redirect_uri
+ QTest::ignoreMessage(QtMsgType::QtDebugMsg, re);
+ QDesktopServices::openUrl(response_redirect_uri);
+ if (matches) {
+ QTRY_VERIFY(!redirectedSpy.isEmpty());
+ QCOMPARE(redirectedSpy.takeFirst().at(0).toMap(), result_parameters);
+ }
+}
+
+QTEST_MAIN(tst_QOAuthUriSchemeReplyHandler)
+#include "tst_oauthurischemereplyhandler.moc"