Using Python to perform a PKCE exchange

Learn how to perform a PKCE exchange with Authproject using Python.

Using Python to perform a PKCE exchange

Today we're going to be learning how to perform a PKCE exchange with Authproject using Python.

Explanation

PKCE is used by client applications to perform authorization code exchanges in a setting where a client secret would not suffice - for example, running an application locally on a user's machine.

How it works

It all starts by generating a code verifier and code challenge. The code verifier is a 64-byte string that is then URL-safe Base64 encoded. The verifier is then hashed using SHA-256, to produce a challenge. The challenge is passed to Authproject (or your IdP of choice) during the authorization code flow. Then, once the authorization code has been received by the application, the verifier is passed during the code exchange to the server. The server validates that the verifier is a valid solution to the challenge (i.e., you can hash the verifier to generate the challenge), an access token is returned, and your application is ready to go.

Attack Surface

This flow is specifically designed to prevent man-in-the-middle (MITM) attacks from grabbing the authorization code during the exchange and presenting it themselves to obtain an access token. Because the verifier is hard to guess from the challenge, it is guaranteed that the process that presents the challenge is the only process that is able to exchange an authorization code for an access token.

Using Python

Let's get started with Python to perform a PKCE exchange.

Dependencies

First, we need to install the Python dependencies. Thankfully, there are only two needed for our application.

pip install requests-oauthlib hashlib

Imports

At the top of our script, we need to import the following libraries

import base64
import webbrowser
import secrets

import hashlib

from requests_oauthlib import OAuth2Session

We need to use base64 and secrets to generate the code verifier and challenge, webbrowser to open up a web browser window to perform the exchange, hashlib to perform the hashing of the verifier, and OAuth2Session from requests_oauthlib to manage the OAuth2 flow.

Global Variables

To make our lives easier, we are going to define some global variables.

# Client ID, provided by Authproject
client_id = "client-f2655980a4574a01b6a501a20676651f"

# Base URL for authorization
authorization_base_url = "https://<your-auth-domain>/oauth2/authorize"

# Token URL for exchanging authorization code for access token
token_url = "https://<your-auth-domain>/oauth2/token"

# Redirect URI registered with the OAuth2 provider
redirect_uri = "https://localhost:9000/callback"

# OAuth2 scope (optional, depends on your server)
scope = ["openid", "profile", "email"]
  • The client ID is provided by Authproject
  • The authorization base URL is the URL the user gets sent to in order to start the OAuth2 flow
  • The token URL is where you exchange an authorization code for an access token (and refresh token)
  • The redirect URI is where the user gets sent after a successful authentication
  • The scope variable is what scopes to request from the IdP

Generate a PKCE Pair

We next need to generate the actual code verifier and code challenge.

def generate_pkce_pair():
    # Generate a high-entropy code verifier
    code_verifier = (
        base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("utf-8")
    )
    # Create code challenge
    code_challenge = (
        base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("utf-8")).digest())
        .rstrip(b"=")
        .decode("utf-8")
    )
    return code_verifier, code_challenge

This function returns two values - the code verifier and the code challenge. It generates a 64-byte secret, then base64 encodes it. Then, it base64 encodes the hash of the verifier, to produce the challenge. This is the most important step in the whole flow - it uses the difficulty of reversing a cryptographic hash to guarantee that only the process that knows the verifier can exchange an authorization code for an access token

Creating the OAuth2Session Object

We need to create an OAuth2Session object to contain all the relevant values during the exchange.

code_verifier, code_challenge = generate_pkce_pair()
# Create an OAuth2 session
oauth = OAuth2Session(
    client_id,
    redirect_uri=redirect_uri,
    scope=scope,
)

It calls the generate_pkce_pair function, to create the values that will be used later. Then, we create the session object.

Start the Exchange

Next, we open a web browser to the authorization code URL created by the session object.

authorization_url, state = oauth.authorization_url(
    authorization_base_url,
    code_challenge=code_challenge,
    code_challenge_method="S256",
)
print("Open this URL in a browser and authorize:", authorization_url)
webbrowser.open(authorization_url)

This code creates the authorization URL, passes in the code challenge, and opens it in the web browser.

Get the Response

We then ask the user to input the URL that they were redirected to. This is because we don't want to run an HTTP server for the sake of this example.

# Get the authorization response from the user
redirect_response = input("Paste the full redirect URL here: ").strip()

Exchange Authorization Code for a Token

Next, we take the redirect response value, pass it to the session object, and let it parse out the values.

# Fetch the access token
token = oauth.fetch_token(
    token_url,
    authorization_response=redirect_response,
    include_client_id=True,
    code_verifier=code_verifier,
)
print("\nAccess token:", token)

This will fetch the token, provide the code verifier, and return a token object.

Putting it All Together

The entire contents of the script should look like this:

import base64
import webbrowser
import secrets

import hashlib

from requests_oauthlib import OAuth2Session

# Client ID, provided by Authproject
client_id = "client-f2655980a4574a01b6a501a20676651f"

# Base URL for authorization
authorization_base_url = "https://<your-auth-domain>/oauth2/authorize"

# Token URL for exchanging authorization code for access token
token_url = "https://<your-auth-domain>/oauth2/token"

# Redirect URI registered with the OAuth2 provider
redirect_uri = "https://localhost:9000/callback"

# OAuth2 scope (optional, depends on your server)
scope = ["openid", "profile", "email"]


def generate_pkce_pair():
    # Generate a high-entropy code verifier
    code_verifier = (
        base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("utf-8")
    )
    # Create code challenge
    code_challenge = (
        base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("utf-8")).digest())
        .rstrip(b"=")
        .decode("utf-8")
    )
    return code_verifier, code_challenge


def main():
    code_verifier, code_challenge = generate_pkce_pair()
    # Create an OAuth2 session
    oauth = OAuth2Session(
        client_id,
        redirect_uri=redirect_uri,
        scope=scope,
    )

    # Get authorization URL and state
    authorization_url, state = oauth.authorization_url(
        authorization_base_url,
        code_challenge=code_challenge,
        code_challenge_method="S256",
    )
    print("Open this URL in a browser and authorize:", authorization_url)
    webbrowser.open(authorization_url)

    # Get the authorization response from the user
    redirect_response = input("Paste the full redirect URL here: ").strip()

    # Fetch the access token
    token = oauth.fetch_token(
        token_url,
        authorization_response=redirect_response,
        include_client_id=True,
        code_verifier=code_verifier,
    )
    print("\nAccess token:", token)


if __name__ == "__main__":
    main()

Note that I have wrapped the main code in a function, which is called only when the script is being run interactively.

Output

The output of the script should look like this:

Open this URL in a browser and authorize: https://<your-auth-domain>/oauth2/authorize?response_type=code&client_id=<client-id>&redirect_uri=<redirect_uri>
Paste the full redirect URL here: https://localhost:9000/callback?code=<authorization-code>&state=<state>

Access token: {'access_token': '<access-token>', 'expires_in': 7200, 'id_token': '<id-token>', 'refresh_token': '<refresh-token>', 'scope': ['openid', 'profile', 'email'], 'token_type': 'Bearer', 'expires_at': 1754238405}

Note that I have removed the actual values, so your output may be considerably longer than is above.

Want to get started with Authproject?

Visit Authproject to get started!