{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# OpenID Connect Flows With An Example Keycloak Setup\n", "\n", "This is a Python3 notebook that illustrates different OpenID Connect flows, using a local Keycloak instance as OpenID provider and some basic libraries to handle the HTTP interactions.\n", "\n", "You can skip the set up part and go straight to the flows:\n", "\n", "- [Client Credentials Flow](#Client-Credentials-Flow)\n", "- [Resource Owner Password Flow](#Resource-Owner-Password-Flow)\n", "- [Authorization Code Flow](#Authorization-Code-Flow)\n", "\n" ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "import base64\n", "import html\n", "import json\n", "import logging\n", "import re\n", "import urllib.parse\n", "import uuid\n", "\n", "import requests" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "logging.basicConfig(level=logging.INFO)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Setup of Keycloak instance\n", "\n", "We need a test/development Keycloak instance.\n", "For example, spin up a local Keycloak instance with Docker as follows:\n", "\n", " docker run --rm -it -p 9090:8080 \\\n", " -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin \\\n", " jboss/keycloak:7.0.0" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "keycloak_base_url = \"http://localhost:9090/auth\"\n", "admin_username = \"admin\"\n", "admin_password = \"admin\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Keycloak Admin API\n", "\n", "To be able to create clients and users through the Keycloak admin API, we first have to obtain an admin access token through OpenID, which we have to use a bearer token for other admin API requests. Let's wrap this stuff in a class." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "class KeycloakAdmin:\n", " def __init__(self, base_url: str, username: str, password: str):\n", " r = requests.post(\n", " base_url + '/realms/master/protocol/openid-connect/token', \n", " data={\n", " \"username\": username,\n", " \"password\": password,\n", " \"grant_type\": \"password\",\n", " \"client_id\": \"admin-cli\"\n", " })\n", " r.raise_for_status()\n", " \n", " self.session = requests.Session()\n", " self.session.headers[\"Authorization\"]= \"Bearer \" + r.json()[\"access_token\"]\n", " self.admin_base_url = base_url + '/admin/realms/master'\n", " self.log = logging.getLogger(\"keycloak-admin\")\n", " \n", " def create_client(self, options: dict = None, prefix: str = \"myclient-\") -> str:\n", " client_id = prefix + uuid.uuid4().hex[:8]\n", " data = {\"id\": client_id}\n", " data.update(options)\n", " self.log.info(\"Creating client with settings {s!r}\".format(s=data))\n", " r = self.session.post(self.admin_base_url + '/clients', json=data)\n", " r.raise_for_status()\n", " return client_id\n", "\n", " def get_client_secret(self, client_id) -> str:\n", " r = self.session.get(self.admin_base_url + '/clients/{c}/client-secret'.format(c=client_id))\n", " r.raise_for_status()\n", " self.log.info(\"Client secret response: {r!r}\".format(r=r.text))\n", " client_secret = r.json()[\"value\"]\n", " return client_secret\n", " \n", " def create_user(self, prefix: str = \"John-\", password: str = \"j0hn\"):\n", " username = prefix + uuid.uuid4().hex[:8]\n", "\n", " r = self.session.post(\n", " self.admin_base_url + '/users', \n", " json={\n", " \"username\": username,\n", " \"credentials\": [\n", " {\"type\": \"password\", \"value\": password, \"temporary\": False},\n", " ],\n", " \"enabled\": True,\n", " }\n", " )\n", " r.raise_for_status()\n", " return username, password\n", "\n", "\n", "# And while we're at it,\n", "def jwt_decode(token: str):\n", " \"\"\"Poor man's JWT decoding\"\"\"\n", "\n", " def _decode(data: str) -> dict:\n", " decoded = base64.b64decode(data + '=' * (4 - len(data) % 4)).decode('ascii')\n", " return json.loads(decoded)\n", "\n", " header, payload, signature = token.split('.')\n", " return _decode(header), _decode(payload)" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "keycloak_admin = KeycloakAdmin(keycloak_base_url, admin_username, admin_password)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## General Set Up" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To better see what is going on the HTTP level when doing OpenID Connect request, we'll add a `requests` hook that prints a bit of request and response info." ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [], "source": [ "from IPython.display import HTML, display\n", "\n", "def _show_request_info(r, *args, **kwargs):\n", " req = r.request\n", " default_headers = requests.utils.default_headers()\n", " headers = {k:v for k,v in req.headers.items() if k not in default_headers}\n", " display(HTML('''
{m} {u}
with {b!r}
{h!r}
GET http://localhost:9090/auth/realms/master/.well-known/openid-configuration
with None
{}
POST http://localhost:9090/auth/realms/master/protocol/openid-connect/token
with 'grant_type=client_credentials&client_id=myclient-5b8dfca4&client_secret=7f39376a-0d3e-4a87-b2ef-a9718bee4a76'
{'Content-Length': '108', 'Content-Type': 'application/x-www-form-urlencoded'}
GET http://localhost:9090/auth/realms/master/protocol/openid-connect/userinfo
with None
{'Authorization': 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJXVEdTMXJyN1pTY2RWU05DV1d5M0tiVUJXNFVaUDc2ZFZ1V1l4RTdYSnYwIn0.eyJqdGkiOiI0ODdiOTYzYy0yYzI0LTQwMmYtODE3OS02YjE1OTAxYjkxODEiLCJleHAiOjE1NzE3MzYwNTQsIm5iZiI6MCwiaWF0IjoxNTcxNzM1OTk0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwOTAvYXV0aC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjZiMTRkYTE2LWM5MjgtNDg4Ni05MmE2LTQyYmE3MjU1YzU0NSIsInR5cCI6IkJlYXJlciIsImF6cCI6Im15Y2xpZW50LTViOGRmY2E0IiwiYXV0aF90aW1lIjowLCJzZXNzaW9uX3N0YXRlIjoiMDNlODcxNmItZWRkZS00YWY4LTk5ZWEtNGI4ZDU5ODQ5OThhIiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwiY2xpZW50SG9zdCI6IjE3Mi4xNy4wLjEiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImNsaWVudElkIjoibXljbGllbnQtNWI4ZGZjYTQiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2aWNlLWFjY291bnQtbXljbGllbnQtNWI4ZGZjYTQiLCJjbGllbnRBZGRyZXNzIjoiMTcyLjE3LjAuMSIsImVtYWlsIjoic2VydmljZS1hY2NvdW50LW15Y2xpZW50LTViOGRmY2E0QHBsYWNlaG9sZGVyLm9yZyJ9.dplwSv8nByYO7Xh7O8Vl1EGAtXLP6W87KGzUOCwN73tCcAJbX3HhbYvP36f489dx1wj8n1Wb_FwNbASED1XGZb_pbps07YO68OlcJjkIafXsLXK98tSaQJ3hurn-lSa8DA3_-A5MnW-XR_7dFad1Guo0RSypv94ybZEFX8RMFWcUeVmsEkRLlAfP2d9WMnZ2N8d08jAr5FBbYedhNBX8VOWIm05Ho5hv8h7OtnS3fLVsfCtm36s6sQCYfGlfgfGc_bg1O9mtPHLfn5sf-j6SUBYpnfIBFXJBx4tChfd4Vryoa5tloqMiRsx1Xq96jiuWGQjLvbKjXTM3n_XB4UVuWQ'}
POST http://localhost:9090/auth/realms/master/protocol/openid-connect/token
with 'grant_type=password&username=John-d2c1d40b&password=j0hn&client_id=myclient-9fcf2d4e'
{'Content-Length': '84', 'Content-Type': 'application/x-www-form-urlencoded'}
GET http://localhost:9090/auth/realms/master/protocol/openid-connect/userinfo
with None
{'Authorization': 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJXVEdTMXJyN1pTY2RWU05DV1d5M0tiVUJXNFVaUDc2ZFZ1V1l4RTdYSnYwIn0.eyJqdGkiOiJkZjkxMjZmZi1lN2YyLTQxYzUtODdiMi1mMmZkMWQwMDM5OGQiLCJleHAiOjE1NzE3MzYwNTQsIm5iZiI6MCwiaWF0IjoxNTcxNzM1OTk0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwOTAvYXV0aC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImFhNGYxZjExLTk5N2EtNDY4Yi05ZDE1LTA1NmMyMjBjMTQ1NSIsInR5cCI6IkJlYXJlciIsImF6cCI6Im15Y2xpZW50LTlmY2YyZDRlIiwiYXV0aF90aW1lIjowLCJzZXNzaW9uX3N0YXRlIjoiNGM5OWQ5ODAtZDVlOC00MzU3LTkxMjQtNjI4NWU3ZGVkNzM3IiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJqb2huLWQyYzFkNDBiIn0.SzQsIuL1O-IIdJIILGq4uAuh8ZXwTtdSiu7TdFZ3uwnM96FGwbGFxP9InlWd-QJijkd3P_01Dh0OP00HtSWkvBseuRKEMnPcGqhaTl89sMzKGxGgvAfVXJXTcsrTPjDA1jG1kJrd4Bqsafy-hNCsTTAo2t3KHKDAQcPRAq31-DqK4pbGDC6NP7y7CKs4e3LoDYOXdtE-IlUwqUTF05XHwhrjzickzS-hj2tnszK-PWndAcmDTljsjhENR_IBM9mjiQNNhqjgvykANSnJlDQxwZIPekFs0_yWoYPy7iAbcVGjCO6GaSHEM--bTdxVFemwHs4Zh7gXuC5IG0_YMxAmqQ'}
GET http://localhost:9090/auth/realms/master/protocol/openid-connect/auth?response_type=code&client_id=myclient-ebda8bec&scope=openid&redirect_uri=https%3A%2F%2Fexample.com%2Fredir&state=foobar
with None
{}