Introduction¶
The last days I spent quite a bit of time reading up on OAuth2 and OpenID Connect. There are a lot of pointers online on this topic, but unfortunately it's not easy to find easy digestible explanations. In case you are still in an unenlighted state and you don't want to read all those dry RFC documents, I can highly recommend the talk OAuth 2.0 and OpenID Connect (in plain English) by Nate Barbettini, which gives a very good introduction of OAuth2, OpenID Connect and how they should be used for authentication and authorization.
When I was looking into the OAuth Implicit flow to use OpenID Connect in a sort of Single Page Application setup, I quickly stumbled on articles recommending against the implicit flow because of security issues. Instead, one should use the authorization code flow with PKCE ("Proof Key for Code Exchange" and apparently to be pronounced as "pixy"). PKCE replaces the static secret used in the authorization flow with a temporary one-time challenge, making it feasible to use in public clients.
Step by step walkthrough in Python¶
In this notebook, I will dive into the OAuth 2.0 Authorization Code flow with PKCE step by step in Python, using a local Keycloak setup as authorization provider. Basic knowledge about OAuth flows and PKCE is assumed, as the discussion will not go into much theoretical details. There is already enough material online on this, written by more knowledgeable people. The focus lies on practical, step by step low-level HTTP operations. We wont even use an actual browser nor need an actual HTTP server for the redirect URL.
Setup¶
We don't require special libraries, just the standard library will do,
except for the well known requests
library to make the HTTP operations a bit simpler.
import base64
import hashlib
import html
import json
import os
import re
import urllib.parse
import requests
We need an OAuth/OpenID Connect provider obviously. Let's run a local instance of Keycloak through docker, for example as follows:
docker run --rm -it -p 9090:8080 \
-e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin \
jboss/keycloak:7.0.0
A detailed discussion of Keycloak is a bit out of scope for this walkthrough. The most important information is that it will act as an OAuth/OpenID Connect provider. Through the administration console (a http://localhost:9090/auth/admin/), we:
- set up a client "pkce-test" (in the "master" realm) with access type "public" (to be able to use PKCE) and the catch-all "*" as valid redirect URIs (which is a required field).
- create a user "john" with non-temporary password.
provider = "http://localhost:9090/auth/realms/master"
client_id = "pkce-test"
username = "john"
password = "j0hn"
redirect_uri = "http://localhost/foobar"
Connect to authentication provider¶
The first phase of the flow is to connect to the OAuth/OpenID Connect provider and authenticate. For a PKCE-enabled flow we need a some PKCE ingredients from the start.
PKCE code verifier and challenge¶
We need a code verifier, which is a long enough random alphanumeric string, only to be used "client side". We'll use a simple urandom/base64 trick to generate one:
code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode('utf-8')
code_verifier = re.sub('[^a-zA-Z0-9]+', '', code_verifier)
code_verifier, len(code_verifier)
To create the PKCE code challenge we hash the code verifier with SHA256 and encode the result in URL-safe base64 (without padding)
code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest()
code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8')
code_challenge = code_challenge.replace('=', '')
code_challenge, len(code_challenge)
Request login page¶
We now have all the pieces for the initial request, which will give us the login page of the authentication provider. Adding the code challenge signals to the OAuth provider that we are expecting the PKCE based flow.
state = "fooobarbaz"
resp = requests.get(
url=provider + "/protocol/openid-connect/auth",
params={
"response_type": "code",
"client_id": client_id,
"scope": "openid",
"redirect_uri": redirect_uri,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
allow_redirects=False
)
resp.status_code
Parse login page (response)¶
Get cookie data from response headers (requires a bit of manipulation).
cookie = resp.headers['Set-Cookie']
cookie = '; '.join(c.split(';')[0] for c in cookie.split(', '))
cookie
Extract the login URL to post to from the page HTML code. Because the the Keycloak login page is straightforward HTML we can get away with some simple regexes.
page = resp.text
form_action = html.unescape(re.search('<form\s+.*?\s+action="(.*?)"', page, re.DOTALL).group(1))
form_action
Do the login (aka authenticate)¶
Now, we post the login form with the user we created earlier, passing it the extracted cookie as well.
resp = requests.post(
url=form_action,
data={
"username": username,
"password": password,
},
headers={"Cookie": cookie},
allow_redirects=False
)
resp.status_code
As expected we are forwarded, let's get the redirect URL.
redirect = resp.headers['Location']
redirect
assert redirect.startswith(redirect_uri)
Extract authorization code from redirect¶
The redirect URL contains the authentication code.
query = urllib.parse.urlparse(redirect).query
redirect_params = urllib.parse.parse_qs(query)
redirect_params
auth_code = redirect_params['code'][0]
auth_code
Exchange authorization code for an access token¶
We can now exchange the authorization code for an access token. In the normal OAuth authorization flow we should include a static secret here, but instead we provide the code verifier here which acts proof that the initial request was done by us.
resp = requests.post(
url=provider + "/protocol/openid-connect/token",
data={
"grant_type": "authorization_code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"code": auth_code,
"code_verifier": code_verifier,
},
allow_redirects=False
)
resp.status_code
In the response we get, among others, the access token and id token:
result = resp.json()
result
Decode the JWT tokens¶
The access and id tokens are JWT tokens apparently. Let's decode the payload.
def _b64_decode(data):
data += '=' * (4 - len(data) % 4)
return base64.b64decode(data).decode('utf-8')
def jwt_payload_decode(jwt):
_, payload, _ = jwt.split('.')
return json.loads(_b64_decode(payload))
jwt_payload_decode(result['access_token'])
jwt_payload_decode(result['id_token'])
Conclusion¶
That's it. We worked client-side-style and managed thanks to PKCE to do a non-implicit authorization flow without having to work with a static secret.