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 | |