diff options
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 Binary files differnew file mode 100644 index 0000000..10af724 --- /dev/null +++ b/src/oauth/doc/images/oauth2-flow-details.webp diff --git a/src/oauth/doc/images/oauth2-stages.webp b/src/oauth/doc/images/oauth2-stages.webp Binary files differnew file mode 100644 index 0000000..8babfa7 --- /dev/null +++ b/src/oauth/doc/images/oauth2-stages.webp 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 ¶meters) { 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" |