from Guide to Hacking on Jun 25, 2023
How third-party login for desktop apps work
At some point, you've likely logged into a desktop app using a single sign on — again such as Google, Facebook, or Twitter. Here is how that desktop login worked and how to build one yourself.
This used to be a seamless experience that was contained in your desktop app end-to-end. However, this was a security risk: Malicious apps could capture your login information with a faked login page.
To combat this, login services started requiring "supported browsers" such as Chrome or Firefox, and this is where implementing third-party logins started to get more complicated. This process is awful confusing, so let's break down how it works and how to build it.
How third-party login used to work
To recap, here's how third-party login within a browser alone works. We'll use the same example as in How third-party login works, where we login to Slack's web app using Google. There are two critical requirements that need to be satisfied, among others:
- Identify the app: Slack must include information that identifies itself, when sending you to Google's signin page.
- Receive authorization token: Google must somehow send a special token back to the Slack web app.
To satisfy these requirements, Slack follows this process for third-party login in a web browser:
- You click "login" on
slack.com
. This satisfies requirement #1. At this point, Slack builds a Google login URL that includes information about the requestor — i.e., Slack. - Your browser navigates you to
accounts.google.com/signin
. - You login normally.
- You grant permission to Slack to access your information.
- You are redirected back to
slack.com
. This satisfies requirement #2. The redirect back to Slack includes the special token. - Slack now uses that special token to ask Google for your information, such as your name and email address.
Let's now implement this flow in a desktop application. For this section only, we'll leverage a unique property of the pywebview
Python-only desktop application — namely, the entire application is a webview. This allows us to redirect to Google completely within the application.
Create a new directory to house your project. I will create one on my desktop.
mkdir ~/Desktop/desktoplogin
cd ~/Desktop/desktoplogin
In this new directory, create a new virtual environment.
python -m venv env
source env/bin/activate
We now need to install the GUI library and web framework packages. Since our list of requirements is growing longer, add your requirements to a file named requirements.txt
.
Flask==2.3.2
simple-flask-google-login==0.3.0
pywebview==4.2.2
cryptography==41.0.1
Then, install your requirements.
pip install -r requirements.txt
Now, start from our previous app.py
in How third-party login works. Add a few extra lines at the bottom of the file to wrap your web server in a desktop application. Your file should then match the following.
from flask import Flask, session
from simple_flask_google_login import SimpleFlaskGoogleLogin
import webview
app = Flask("Google Login App")
app.secret_key = "YourSecretKeyHere" # Secret key is needed for OAuth 2.0
login = SimpleFlaskGoogleLogin(app)
@app.route("/")
def index():
if 'name' in session:
return f"Hello {session['name']}! <a href='/logout'>Logout</a>"
return f"<a href='/login'>Login</a>"
if __name__ == '__main__':
keyfile, certfile = webview.generate_ssl_cert()
ssl = {'keyfile': keyfile, 'certfile': certfile}
webview.create_window('Flask example', app, http_port=5000, server_args=ssl)
webview.start()
Optionally, check against our reference implementation. Launch the desktop application with the following
python app.py
You should then see a desktop application like the following popup.
Click Login and follow the onscreen prompts.
Once logged in, you should now see a confirmation screen, showing a successful login.
At this point, you've completed your very first desktop application with third-party login. With that said, you'll notice this flow is different from other desktop applications. Other applications will redirect you to your browser, for security and for convenience. Let's implement this more secure option now.
How third-party login now works on desktop
You've likely gone through this flow multiple times, so the process will sound familiar. Let's say you're trying to sign into Slack using your Google account; your default browser is Chrome. There are four requirements:
- Identify the app: Slack must include information that identifies itself, when sending you Google's signin page.
- Use a browser: User must login in a secure, pre-approved browser such as Chrome, rather than in an unsafe embedded browser
- Catch non-users: Slack wants to catch users that may have entered the login flow without installing the application.
- Receive authorization token: Google must somehow send a special login code back to the Slack desktop app.
Here is the flow from your perspective as the user. Additionally, here's how these steps address the requirements above.
- Click "login" inside of the Slack desktop app. This satisfies requirement #1. Slack builds a URL that contains information about itself.
- This pops you out of Slack and into Chrome, which opens Google's login page. This satisfies requirement #2. You are now signing in via secure, pre-approved browser.
- In Chrome, you login normally.
- In Chrome, you are redirected to the Slack website. This partially satisfies requirement #4, as the redirect request includes a special code. Now, the Slack website has access to the special code.
- A popup asks if you want to open a link in the Slack desktop app. This satisfies requirement #3. If the Slack desktop app is not installed, you will be prompted at this point to access the Slack web app instead.
- Once you confirm, you are redirected back to your Slack desktop app, and login has now been completed. This satisfies requirement #4. The redirect request again includes the same special login code. Now, the Slack desktop app has the special code too and can complete login.
Here's the process in more technical detail and which steps address which requirements above.
-
You click "login" in the Slack desktop app.
- Slack app creates a login page URL using some application identifiers and a redirect URL like
slack.com/success
. This satisfies requirement #1.
- Slack app creates a login page URL using some application identifiers and a redirect URL like
-
This pops you out of Slack and into Chrome, which opens the link generated above,
accounts.google.com/signin?somedata=here
.- This satisfies requirement #2, where you must signin through a known, secure browser.
-
In Chrome, you login normally.
-
In Chrome, you are redirected back to
slack.com/success?code=asdf
. Notice the redirect contains a special code in the query parameter.- This query parameter partially satisfies requirement #4, as the code has been passed from Google to
slack.com/success
. However, we now need to pass the code fromslack.com/success
to the Slack desktop app.
- This query parameter partially satisfies requirement #4, as the code has been passed from Google to
-
In Chrome,
slack.com/success
asks if you want to open a link in Slack. This link is a deeplink likeslack://signin?code=asdf
that will pass the special code to your desktop app.- This redirect satisfies requirement #3. If you logged in without the Slack app, then you won't see a popup. Instead, you'll see just this webpage
slack.com/success
, which asks if you want to signin to the web version of Slack.
- This redirect satisfies requirement #3. If you logged in without the Slack app, then you won't see a popup. Instead, you'll see just this webpage
-
Once you confirm opening the deeplink, you'll be redirected back to the Slack app.
- This finally fully satisfies requirement #4, as you've now passed the special code back to the Slack desktop app via the deeplink
slack://signin?code=asdf
. - Now, the Slack desktop uses the code to complete authentication with Google. From the user's perspective, signin is complete.
- This finally fully satisfies requirement #4, as you've now passed the special code back to the Slack desktop app via the deeplink
This completes our introduction to third-party logins on desktop. Now that you have the process down, let's augment our desktop application to follow this process.
How to build third-party login on desktop
To build this new flow into our desktop application, we need a few changes:
- Redirect the user to the browser: Upon clicking Login, the user should be redirected to the browser instead of loading a webpage within the desktop application.
- Intermediate webpage suggesting deeplink: We will setup an intermediate webpage, which Google will redirect to. This intermediate webpage will then prompt the user to open the deeplink if they have the desktop application installed. If the application is not installed, the user will be prompted to install it.
- Accept deeplink with login token: After the user confirms opening the deeplink, the user is redirected back to the desktop application. We need to setup the application to accept this custom deeplink and to process the login token contained in that deeplink.
Let's now effect each of these changes.
Step 1 — Redirect the user to the browser.
To do this, we'll leverage two existing features of pywebview:
- Any link with the target set to
_blank
will open in the browser. - Use Python's built-in
webbrowser.open
(documentation), which opens links in browsers and is supported by pywebview.
In your main app.py, customize your login endpoint to open the web browser and to show a clickable link if browser-opening fails.
import webbrowser
def url_handler(url):
webbrowser.open(url)
return f"If you aren't redirected automatically, click <a href='{url}' target='_blank'>here</a>"
Additionally, configure your application to use your custom handler.
login = SimpleFlaskGoogleLogin(app, authorization_url_handler=url_handler)
Check with our reference implementation. Launch the application to test this flow, using the following.
python app.py
Once the desktop application is launched, click on Login and you should be redirected to a Google login page in your browser. If you attempt to login, you'll be redirected to an invalid address. This is normal, and we'll update this redirect next.
Step 2 — Redirect to an intermediate webpage.
We'll now setup an intermediate webpage on Glitch. This webpage will then prompt the user to open a deeplink and complete login.
Click on this link to make a copy of our template redirect webpage: https://glitch.com/edit/#!/remix/desktop-login.
In your remixed project, you'll then find the following in index.html
.
glitch/index.html
<html>
<p>If you aren't redirected automatically, click <a href="" id="link">here</a>.</p>
<script>
window.onload = function() {
const url = 'myapp://login' + window.location.search;
document.getElementById('link').setAttribute('href', url);
window.location.replace(url);
}
</script>
</html>
The above webpage prompts the user to open a deeplink, and if the automatic prompt fails to show, provides a clickable deeplink.
On the bottom left, click on Preview, then Preview in a new window. You should then see a webpage like the following, prompting you to open a deeplink.
Copy the URL for your webpage. In our example above, the URL is https://grandiose-glow-golf.glitch.me. Amend your login configuration, with the following.
login = SimpleFlaskGoogleLogin(
app, authorization_url_handler=url_handler,
redirect_uri='https://desktop-login.glitch.me'
)
Additionally, access your Oauth configuration page in your Google developer console's credentials page. Add this address to your list of Authorized Redirect URIs.
At this point, we no longer need to serve the local application over https. Additionally, we'll be using PyQt shortly, whose web engine does not accept self-signed certificates2. Fortunately, since our redirect is an intermediate webpage with an https protocol, this change is acceptable3.
At this point, we also no longer need to fix the port to 5000; previously, we fixed the port so that we could construct redirect_uri
using the local server's host URL. Now, the redirect_uri
is a different server entirely.
Simplify your app configuration to the following.
if __name__ == '__main__':
webview.create_window('Flask example', app)
webview.start()
Check against our reference implementation. Launch the application to test this flow, using the following.
python app.py
Once the desktop application is launched, click on Login. You will be redirected to your browser, where you can login with Google. Google should then redirect you to the Glitch URL above, where you'll be prompted to open a deeplink. At this point, the deeplink is not yet functional.
Step 3 — Accept a deeplink.
We'll follow the same process we did in How a Python-only desktop app works. Add some requirements to your requirements.txt
.
py2app==0.28.6
qtpy==2.3.1
pyqt6==6.5.1
PyQt6-WebEngine==6.5.0
Then, install all requirements.
pip install -r requirements.txt
Define a configuration file for building your desktop application. The below file includes the following:
- The
OPTIONS
dictionary defines the custom URL protocol, as we discussed in How a Python-only deeplink works. - The
data_files
keyword argument specifies which non-Python files to include in the package. In our case, we need to package our client secrets along with the source code.
Save the following in setup.py
.
from setuptools import setup
OPTIONS = {
'plist': {
'CFBundleURLTypes': [ # for custom URL schemes (i.e., deeplinks)
{
'CFBundleURLName': 'MyApplication', # arbitrary
'CFBundleURLSchemes': ['myapp'], # deeplink will be myapp://
},
],
},
}
setup(
app=['app.py'], # your main application
options={'py2app': OPTIONS},
setup_requires=['py2app', 'pywebview', 'qtpy', 'pyqt6', 'PyQt6-WebEngine'],
data_files=['client_secrets.json'],
)
In your app.py
, underneath your existing imports, define a custom application with an event handler. For now, simply print the deeplink.
import sys
from qtpy.QtWidgets import QApplication
from qtpy.QtCore import QEvent
import webview.platforms.qt # needed for us to define a custom application
class MyApp(QApplication):
def event(self, event: QEvent): # override the event method
if event.type() == QEvent.Type.FileOpen: # filter the File Open event
print(event.url())
return super(QApplication, self).event(event)
Instantiate your custom application, and change webview's backend to be qt.
if __name__ == '__main__':
application = MyApp(sys.argv)
webview.create_window('Flask example', app)
webview.start(gui='qt')
Check against our reference implementation. Let's now build our desktop application. To build the desktop application, use the py2app
command.
python setup.py py2app -A
Move the built application to the ~/Applications
folder.
mv ./dist/app.app /Applications
Open the application once, normally. Run the following to do so, with logs printed to your console.
/Applications/app.app/Contents/MacOS/app
Now, you can test your deeplink by opening myapp://whatever_you_like_here
. You can open this in your browser, or by typing the following in your terminal on Mac.
open myapp://whatever_you_like_here
Either way, you should see the deeplink being printed in your console, like the following.
PyQt6.QtCore.QUrl('myapp://whatever_you_want_here')
With our deeplink now working, let's finish integrating and handling the deeplink.
Step 4 — Accept a deeplink with the login token.
Now, expose the window globally, so that we can reference and redirect this window in our event handler.
if __name__ == '__main__':
application = MyApp(sys.argv)
window = webview.create_window('Flask example', app)
webview.start(gui='qt')
Now, in your event handler, perform the following two steps:
- Check the login code was passed in the deeplink.
- If so, ask the application to redirect the main webview to the login token handler, just as our previous in-app example worked.
class MyApp(QApplication):
def event(self, event: QEvent):
if event.type() == QEvent.Type.FileOpen:
url = event.url()
if 'code=' in url.query():
window.load_url(f"/login/callback?{url.query()}")
return super(QApplication, self).event(event)
This now completes our login flow. Check your source code against our reference implementation. Open the application to try the flow.
open /Applications/app.app
With the application now open, you can once again click on the Login button to start the login process.
Now, you'll be sent to the browser to login normally via Google.
From Google, you'll then be redirected to your intermediate webpage at Glitch, where you'll see the prompt to open the deeplink in your desktop application.
Once you follow the deeplink, your application will then complete the login flow by extracting and sending the login token to your Flask app. At that point, your Flask endpoint will complete login. Just like before, once your login is successful, you should see a confirmation screen like the below.
Success! At this point, you've now successfully written a desktop application supporting third-party login, using a secure and convenient flow for users to follow.
Conclusion
In this post, you've covered how third-party login for desktop works, in a secure and convenient method for users. You've also leveraged what we learned previously in browser-based Python-only development of desktop applications.
You should now have a solid foundation in both understanding third-party login flows, as well as an understanding of the implementation. For the source code for all steps, as well as the finished product, see my "Guide to Hacking" repository.
-
We use
pythonw
instead, as this executable allows Python libraries to hook into the GUI event loop. In other words, your Python desktop application can now capture keyboard inputs and mouse clicks. The vanillavenv
tool that ships with Python doesn't have this issue. ↩ -
According to SO, PyQt does not accept self-signed certificates like ours. To sidestep this, it's technically possible to force acceptance by ignoring the certificate error. To do this, you would monkey patch
webview.platforms.qt.QWebPage
to handle and ignore certificate errors usingerror.ignoreCertificateError()
. With that said, in my attempt to do this, the error was successfully ignored but the webview was blank. ↩ -
In the Oauth flow for third-party login, the redirect URL must use the https protocol. Fortunately for us, our redirect URL is now a Glitch intermediate webpage that indeed has the https protocol. As a result, we satisfy this requirement. Given the requirement is satisfied, we can demote our local server from https to http. This ensures that the PyQt webview can load our local web server without issues. ↩
Want more tips? Drop your email, and I'll keep you in the loop.