| 1 | // Copyright (C) 2024 The Qt Company Ltd. |
| 2 | // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only |
| 3 | |
| 4 | #include "qabstractoauthreplyhandler_p.h" // for lcReplyHandler() |
| 5 | #include "qoauthoobreplyhandler_p.h" |
| 6 | #include "qoauthurischemereplyhandler.h" |
| 7 | |
| 8 | #include <QtGui/qdesktopservices.h> |
| 9 | |
| 10 | #include <private/qobject_p.h> |
| 11 | |
| 12 | #include <QtCore/qloggingcategory.h> |
| 13 | #include <QtCore/qurlquery.h> |
| 14 | |
| 15 | QT_BEGIN_NAMESPACE |
| 16 | |
| 17 | /*! |
| 18 | \class QOAuthUriSchemeReplyHandler |
| 19 | \inmodule QtNetworkAuth |
| 20 | \ingroup oauth |
| 21 | \since 6.8 |
| 22 | |
| 23 | \brief Handles private/custom and https URI scheme redirects. |
| 24 | |
| 25 | This class serves as a reply handler for |
| 26 | \l {https://datatracker.ietf.org/doc/html/rfc6749}{OAuth 2.0} authorization |
| 27 | processes that use private/custom or HTTPS URI schemes for redirection. |
| 28 | It manages the reception of the authorization redirection (also known as the |
| 29 | callback) and the subsequent acquisition of access tokens. |
| 30 | |
| 31 | The \l {https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2} |
| 32 | {redirection URI} is where the authorization server redirects the |
| 33 | user-agent (typically, and preferably, the system browser) once |
| 34 | the authorization part of the flow is complete. |
| 35 | |
| 36 | The use of specific URI schemes requires configuration at the |
| 37 | operating system level to associate the URI with |
| 38 | the correct application. The way to set up this association varies |
| 39 | between operating systems. See \l {Platform Support and Dependencies}. |
| 40 | |
| 41 | This class complements QOAuthHttpServerReplyHandler, |
| 42 | which handles \c http schemes by setting up a localhost server. |
| 43 | |
| 44 | The following code illustrates the usage. First, the needed variables: |
| 45 | |
| 46 | \snippet src_oauth_replyhandlers.cpp uri-variables |
| 47 | |
| 48 | Followed up by the OAuth setup (error handling omitted for brevity): |
| 49 | |
| 50 | \snippet src_oauth_replyhandlers.cpp uri-oauth-setup |
| 51 | |
| 52 | Finally, we then set up the URI scheme reply-handler: |
| 53 | |
| 54 | \snippet src_oauth_replyhandlers.cpp uri-handler-setup |
| 55 | |
| 56 | \section1 Private/Custom URI Schemes |
| 57 | |
| 58 | Custom URI schemes typically use reverse-domain notation followed |
| 59 | by a path, or occasionally a host/host+path: |
| 60 | \badcode |
| 61 | // Example with path: |
| 62 | com.example.myapp:/oauth2/callback |
| 63 | // Example with host: |
| 64 | com.example.myapp://oauth2.callback |
| 65 | \endcode |
| 66 | |
| 67 | \section1 HTTPS URI Scheme |
| 68 | |
| 69 | With HTTPS URI schemes, the redirect URLs are regular https links: |
| 70 | \badcode |
| 71 | https://myapp.example.com/oauth2/callback |
| 72 | \endcode |
| 73 | |
| 74 | These links are called |
| 75 | \l {https://developer.apple.com/ios/universal-links/}{Universal Links} |
| 76 | on iOS and |
| 77 | \l {https://developer.android.com/training/app-links}{App Links on Android}. |
| 78 | |
| 79 | The use of https schemes is recommended as it provides additional security |
| 80 | by forcing application developers to prove ownership of the URLs used. This |
| 81 | proving is done by hosting an association file, which the operating system |
| 82 | will consult as part of its internal URL dispatching. |
| 83 | |
| 84 | The content of this file associates the application and the used URLs. |
| 85 | The association files must be publicly accessible without any HTTP |
| 86 | redirects. In addition, the hosting site must have valid certificates |
| 87 | and, at least with Android, the file must be served as |
| 88 | \c application/json content-type (refer to your server's configuration |
| 89 | guide). |
| 90 | |
| 91 | In addition, https links can provide some usability benefits: |
| 92 | \list |
| 93 | \li The https URL doubles as a regular https link. If the |
| 94 | user hasn't installed the application (since the URL wasn't handled |
| 95 | by any application), the https link may for example serve |
| 96 | instructions to do so. |
| 97 | \li The application selection dialogue to open the URL may be avoided, |
| 98 | and instead your application may be opened automatically |
| 99 | \endlist |
| 100 | |
| 101 | The tradeoff is that this requires extra setup as you need to set up this |
| 102 | publicly-hosted association file. |
| 103 | |
| 104 | \section1 Platform Support and Dependencies |
| 105 | |
| 106 | Currently supported platforms are Android, iOS, and macOS. |
| 107 | |
| 108 | URI scheme listening is based on QDesktopServices::setUrlHandler() |
| 109 | and QDesktopServices::unsetUrlHandler(). These are currently |
| 110 | provided by Qt::Gui module and therefore QtNetworkAuth module |
| 111 | depends on Qt::Gui. If QtNetworkAuth is built without Qt::Gui, |
| 112 | QOAuthUriSchemeReplyHandler will not be included. |
| 113 | |
| 114 | \section2 Android |
| 115 | |
| 116 | On \l {Qt for Android}{Android} the URI schemes require: |
| 117 | \list |
| 118 | \li Setting up |
| 119 | \l {configuring qdesktopservices url handler on android}{intent-filters} |
| 120 | in the application manifest |
| 121 | \li Optionally, for automatic verification with https schemes, |
| 122 | hosting a site association file |
| 123 | \l {configuring qdesktopservices url handler on android}{assetlinks.json} |
| 124 | \endlist |
| 125 | |
| 126 | See also the |
| 127 | \l {https://doc.qt.io/qt-6/android-manifest-file-configuration.html} |
| 128 | {Qt Android Manifest File Configuration}. |
| 129 | |
| 130 | \section2 iOS and macOS |
| 131 | |
| 132 | On \l {Qt for iOS}{iOS} and \l {Qt for macOS}{macOS} the URI schemes require: |
| 133 | \list |
| 134 | \li Setting up site association |
| 135 | \l {configuring qdesktopservices url handler on ios and macos}{entitlement} |
| 136 | \li With https schemes, hosting a |
| 137 | \l {configuring qdesktopservices url handler on ios and macos}{site association file} |
| 138 | (\c apple-app-site-association) |
| 139 | \endlist |
| 140 | |
| 141 | \section2 \l {Qt for Windows}{Windows}, \l {Qt for Linux/X11}{Linux} |
| 142 | |
| 143 | Currently not supported. |
| 144 | */ |
| 145 | |
| 146 | class QOAuthUriSchemeReplyHandlerPrivate : public QOAuthOobReplyHandlerPrivate |
| 147 | { |
| 148 | Q_DECLARE_PUBLIC(QOAuthUriSchemeReplyHandler) |
| 149 | |
| 150 | public: |
| 151 | bool hasValidRedirectUrl() const |
| 152 | { |
| 153 | // RFC 6749 Section 3.1.2 |
| 154 | return redirectUrl.isValid() |
| 155 | && !redirectUrl.scheme().isEmpty() |
| 156 | && redirectUrl.fragment().isEmpty(); |
| 157 | } |
| 158 | |
| 159 | void _q_handleRedirectUrl(const QUrl &url) |
| 160 | { |
| 161 | Q_Q(QOAuthUriSchemeReplyHandler); |
| 162 | // Remove the query parameters from comparison, and compare them manually (the parameters |
| 163 | // of interest like 'code' and 'state' are received as query parameters and comparison |
| 164 | // would always fail). Fragments are removed as some servers (eg. Reddit) seem to add some, |
| 165 | // possibly for some implementation consistency with other OAuth flows where fragments |
| 166 | // are actually used. |
| 167 | bool urlMatch = url.matches(url: redirectUrl, options: QUrl::RemoveQuery | QUrl::RemoveFragment); |
| 168 | |
| 169 | const QUrlQuery responseQuery{url}; |
| 170 | if (urlMatch) { |
| 171 | // Verify that query parameters that are part of redirect URL are present in redirection |
| 172 | const auto registeredItems = QUrlQuery{redirectUrl}.queryItems(); |
| 173 | for (const auto &item: registeredItems) { |
| 174 | if (!responseQuery.hasQueryItem(key: item.first) |
| 175 | || responseQuery.queryItemValue(key: item.first) != item.second) { |
| 176 | urlMatch = false; |
| 177 | break; |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | if (!urlMatch) { |
| 183 | qCDebug(lcReplyHandler(), "Url ignored" ); |
| 184 | // The URLs received here might be unrelated. Further, in case of "https" scheme, |
| 185 | // the first request issued to the authorization server comes through here |
| 186 | // (if this handler is listening) |
| 187 | QDesktopServices::openUrl(url); |
| 188 | return; |
| 189 | } |
| 190 | |
| 191 | qCDebug(lcReplyHandler(), "Url handled" ); |
| 192 | |
| 193 | QVariantMap resultParameters; |
| 194 | const auto responseItems = responseQuery.queryItems(encoding: QUrl::FullyDecoded); |
| 195 | for (const auto &item : responseItems) |
| 196 | resultParameters.insert(key: item.first, value: item.second); |
| 197 | |
| 198 | emit q->callbackReceived(values: resultParameters); |
| 199 | } |
| 200 | |
| 201 | public: |
| 202 | QUrl redirectUrl; |
| 203 | bool listening = false; |
| 204 | }; |
| 205 | |
| 206 | /*! |
| 207 | \fn QOAuthUriSchemeReplyHandler::QOAuthUriSchemeReplyHandler() |
| 208 | |
| 209 | Constructs a QOAuthUriSchemeReplyHandler object with empty callback()/ |
| 210 | redirectUrl() and no parent. The constructed object does not automatically |
| 211 | listen. |
| 212 | */ |
| 213 | |
| 214 | /*! |
| 215 | Constructs a QOAuthUriSchemeReplyHandler object with \a parent and empty |
| 216 | callback()/redirectUrl(). The constructed object does not automatically listen. |
| 217 | */ |
| 218 | QOAuthUriSchemeReplyHandler::QOAuthUriSchemeReplyHandler(QObject *parent) : |
| 219 | QOAuthOobReplyHandler(*new QOAuthUriSchemeReplyHandlerPrivate(), parent) |
| 220 | { |
| 221 | } |
| 222 | |
| 223 | /*! |
| 224 | Constructs a QOAuthUriSchemeReplyHandler object and sets \a parent as the |
| 225 | parent object and \a redirectUrl as the redirect URL. The constructed |
| 226 | object attempts automatically to listen. |
| 227 | |
| 228 | \sa redirectUrl(), setRedirectUrl(), listen(), isListening() |
| 229 | */ |
| 230 | QOAuthUriSchemeReplyHandler::QOAuthUriSchemeReplyHandler(const QUrl &redirectUrl, QObject *parent) |
| 231 | : QOAuthUriSchemeReplyHandler(parent) |
| 232 | { |
| 233 | Q_D(QOAuthUriSchemeReplyHandler); |
| 234 | d->redirectUrl = redirectUrl; |
| 235 | listen(); |
| 236 | } |
| 237 | |
| 238 | /*! |
| 239 | Destroys the QOAuthUriSchemeReplyHandler object. Closes |
| 240 | this handler. |
| 241 | |
| 242 | \sa close() |
| 243 | */ |
| 244 | QOAuthUriSchemeReplyHandler::~QOAuthUriSchemeReplyHandler() |
| 245 | { |
| 246 | close(); |
| 247 | } |
| 248 | |
| 249 | QString QOAuthUriSchemeReplyHandler::callback() const |
| 250 | { |
| 251 | Q_D(const QOAuthUriSchemeReplyHandler); |
| 252 | return d->redirectUrl.toString(); |
| 253 | } |
| 254 | |
| 255 | /*! |
| 256 | \property QOAuthUriSchemeReplyHandler::redirectUrl |
| 257 | \brief The URL used to receive authorization redirection/response. |
| 258 | |
| 259 | This property is used as the |
| 260 | \l{https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2} |
| 261 | {OAuth2 redirect_uri parameter}, which is sent as part of the |
| 262 | authorization request. The \c redirect_uri is acquired by |
| 263 | calling QUrl::toString() with default options. |
| 264 | |
| 265 | The URL must match the one registered at the authorization server, |
| 266 | as the authorization servers likely reject any mismatching redirect_uris. |
| 267 | |
| 268 | Similarly, when this handler receives the redirection, |
| 269 | the redirection URL must match the URL set here. The handler |
| 270 | compares the scheme, host, port, path, and any |
| 271 | query items that were part of the URL set by this method. |
| 272 | |
| 273 | The URL is handled only if all of these match. The comparison of query |
| 274 | parameters excludes any additional query parameters that may have been set |
| 275 | at server-side, as these contain the actual data of interest. |
| 276 | */ |
| 277 | void QOAuthUriSchemeReplyHandler::setRedirectUrl(const QUrl &url) |
| 278 | { |
| 279 | Q_D(QOAuthUriSchemeReplyHandler); |
| 280 | if (url == d->redirectUrl) |
| 281 | return; |
| 282 | |
| 283 | if (d->listening) { |
| 284 | close(); // close previous url listening first |
| 285 | d->redirectUrl = url; |
| 286 | listen(); |
| 287 | } else { |
| 288 | d->redirectUrl = url; |
| 289 | } |
| 290 | emit redirectUrlChanged(); |
| 291 | } |
| 292 | |
| 293 | QUrl QOAuthUriSchemeReplyHandler::redirectUrl() const |
| 294 | { |
| 295 | Q_D(const QOAuthUriSchemeReplyHandler); |
| 296 | return d->redirectUrl; |
| 297 | } |
| 298 | |
| 299 | /*! |
| 300 | Tells this handler to listen for incoming URLs. Returns |
| 301 | \c true if listening is successful, and \c false otherwise. |
| 302 | |
| 303 | The handler will match URLs to redirectUrl(). |
| 304 | If the received URL does not match, it will be forwarded to |
| 305 | QDesktopServices::openURL(). |
| 306 | |
| 307 | Active listening is only required when performing the initial |
| 308 | authorization phase, typically initiated by a |
| 309 | QOAuth2AuthorizationCodeFlow::grant() call. |
| 310 | |
| 311 | It is recommended to close the listener after successful authorization. |
| 312 | Listening is not needed for |
| 313 | \l {QOAuth2AuthorizationCodeFlow::requestAccessToken()}{acquiring access tokens}. |
| 314 | */ |
| 315 | bool QOAuthUriSchemeReplyHandler::listen() |
| 316 | { |
| 317 | Q_D(QOAuthUriSchemeReplyHandler); |
| 318 | if (d->listening) |
| 319 | return true; |
| 320 | |
| 321 | if (!d->hasValidRedirectUrl()) { |
| 322 | qCWarning(lcReplyHandler(), "listen(): callback url not valid" ); |
| 323 | return false; |
| 324 | } |
| 325 | qCDebug(lcReplyHandler(), "listen() URL listener" ); |
| 326 | QDesktopServices::setUrlHandler(scheme: d->redirectUrl.scheme(), receiver: this, method: "_q_handleRedirectUrl" ); |
| 327 | |
| 328 | d->listening = true; |
| 329 | return true; |
| 330 | } |
| 331 | |
| 332 | /*! |
| 333 | Tells this handler to stop listening for incoming URLs. |
| 334 | |
| 335 | \sa listen(), isListening() |
| 336 | */ |
| 337 | void QOAuthUriSchemeReplyHandler::close() |
| 338 | { |
| 339 | Q_D(QOAuthUriSchemeReplyHandler); |
| 340 | if (!d->listening) |
| 341 | return; |
| 342 | |
| 343 | qCDebug(lcReplyHandler(), "close() URL listener" ); |
| 344 | QDesktopServices::unsetUrlHandler(scheme: d->redirectUrl.scheme()); |
| 345 | d->listening = false; |
| 346 | } |
| 347 | |
| 348 | /*! |
| 349 | Returns \c true if this handler is currently listening, |
| 350 | and \c false otherwise. |
| 351 | |
| 352 | \sa listen(), close() |
| 353 | */ |
| 354 | bool QOAuthUriSchemeReplyHandler::isListening() const noexcept |
| 355 | { |
| 356 | Q_D(const QOAuthUriSchemeReplyHandler); |
| 357 | return d->listening; |
| 358 | } |
| 359 | |
| 360 | QT_END_NAMESPACE |
| 361 | |
| 362 | #include "moc_qoauthurischemereplyhandler.cpp" |
| 363 | |