Keycloak has an administration REST API to create realms, clients, users, etc., but its documentation can be a bit terse in some places, making it a bit challenging to connect the dots. In this notebook I'll try to flesh out the basics using Python and the requests package.

We'll work against a development Keycloak instance, e.g. running locally in Docker as follows

docker run --rm \
    -p 8642:8080 \
    -e KEYCLOAK_ADMIN=admin \
    -e KEYCLOAK_ADMIN_PASSWORD=admin \
    quay.io/keycloak/keycloak:21.0.2 \
    start-dev

Note that we're remapping port 8080 (to 8642 in this case) to have a lower chance of collision with some other web service that is already running locally.

The Keycloak administration web UI is available at http://localhost:8642/admin, which is handy to follow along while we're trying to do things programmatically.

Ok, let's go

In [1]:
import requests
In [2]:
keycloak_root = "http://localhost:8642"
keycloak_admin = "admin"
keycloak_admin_password = "admin"

Get an access token

First, we have to authenticate and get an access token for the admin user, which we'll need to do the administration operations.

In [3]:
resp = requests.post(
    f"{keycloak_root}/realms/master/protocol/openid-connect/token",
    data={
        "client_id": "admin-cli",
        "username": keycloak_admin,
        "password": keycloak_admin_password,
        "grant_type": "password"
    }
)
resp.raise_for_status()
data = resp.json()
access_token = data["access_token"]
print(f"{access_token[:20]}...{access_token[-20:]}")
print(f"Expires in {data['expires_in']}s")

# Predefine authorization headers for later use.
auth_headers = {
    "Authorization": f"Bearer {access_token}",
}
eyJhbGciOiJSUzI1NiIs...g4Fs83jFQO2ofLXeOHwQ
Expires in 60s

Note that the access token is only valid for a relatively short time (1 minute in this case). If you execute this notebook flow at your own pace, you might get HTTPError: 401 Client Error: Unauthorized for url errors when the access token expired and have to request new access tokens (e.g. by re-executing the cell above).

Let's verify if we can use the access token (which we already wrapped in a headers dictionary), by trying to list the available realms (GET /admin/realms endpoint):

In [4]:
resp = requests.get(
    f"{keycloak_root}/admin/realms",
    headers=auth_headers,
)
resp.raise_for_status()
[r["realm"] for r in resp.json()]
Out[4]:
['master']

Create a new realm

The Keycloak documentation recommends against using the default realm "master" for your own applications, so we create a new realm first, with the POST /admin/realms endpoint:

In [5]:
# Name of our fancy realm.
realm = "keldor"
In [6]:
resp = requests.post(
    f"{keycloak_root}/admin/realms",
    headers=auth_headers,
    json={
        "realm": realm,
        "enabled": True
    }
)
resp.raise_for_status()

Let's see if that worked and list the realms again:

In [7]:
resp = requests.get(
    f"{keycloak_root}/admin/realms",
    headers=auth_headers,
)
resp.raise_for_status()
[r["realm"] for r in resp.json()]
Out[7]:
['keldor', 'master']

Create a client

To create a client, we use the POST /admin/realms/{realm}/clients endpoint and pass it a bunch of desired settings (including but not limited to the settings below):

In [8]:
client_id = "fancy-client"

client_settings = {
    "protocol": "openid-connect",
    "clientId": client_id,
    "enabled": True,
    # Public: no client secret. Non-public: "confidential" client with secret.
    "publicClient": True,
    # Authorization Code Flow
    "standardFlowEnabled": True,
    # Service accounts: Client Credentials Grant
    "serviceAccountsEnabled": False,
    # Direct Access: Resource Owner Password Credentials Grant
    "directAccessGrantsEnabled": True,
    "attributes": {
        # Device authorization grant
        "oauth2.device.authorization.grant.enabled": True,
    }
}

resp = requests.post(
    f"{keycloak_root}/admin/realms/{realm}/clients",
    json=client_settings,
    headers=auth_headers,
)
resp.raise_for_status()
location = resp.headers["Location"]
print(location)
http://localhost:8642/admin/realms/keldor/clients/6c624d00-c740-4bce-89e6-ba7e7a88858b

We successfully created a client and received, with a Location header, a URL to look up its actual settings:

In [9]:
requests.get(
    location,
    headers=auth_headers,
).json()
Out[9]:
{'id': '6c624d00-c740-4bce-89e6-ba7e7a88858b',
 'clientId': 'fancy-client',
 'surrogateAuthRequired': False,
 'enabled': True,
 'alwaysDisplayInConsole': False,
 'clientAuthenticatorType': 'client-secret',
 'redirectUris': [],
 'webOrigins': [],
 'notBefore': 0,
 'bearerOnly': False,
 'consentRequired': False,
 'standardFlowEnabled': True,
 'implicitFlowEnabled': False,
 'directAccessGrantsEnabled': True,
 'serviceAccountsEnabled': False,
 'publicClient': True,
 'frontchannelLogout': False,
 'protocol': 'openid-connect',
 'attributes': {'oauth2.device.authorization.grant.enabled': 'true',
  'backchannel.logout.session.required': 'true',
  'backchannel.logout.revoke.offline.tokens': 'false'},
 'authenticationFlowBindingOverrides': {},
 'fullScopeAllowed': True,
 'nodeReRegistrationTimeout': -1,
 'defaultClientScopes': ['web-origins', 'acr', 'roles', 'profile', 'email'],
 'optionalClientScopes': ['address',
  'phone',
  'offline_access',
  'microprofile-jwt'],
 'access': {'view': True, 'configure': True, 'manage': True}}

Create a user

Likewise, we can create a new user as follows.

In [10]:
username = "alice"
password = "wonderland"

user_settings = {
    "username": username,
    "enabled": True,
    "credentials": [{
        "type": "password",
        "value": password,
        "temporary": False,
    }]
}


resp = requests.post(
    f"{keycloak_root}/admin/realms/{realm}/users",
    json=user_settings,
    headers=auth_headers,
)
resp.raise_for_status()
location = resp.headers["Location"]
print(location)
http://localhost:8642/admin/realms/keldor/users/fdd3d1f2-d7ee-4967-8d3a-919e95befa85

Again, using the Location header after successfully creating the user, we can inspect the actual user settings:

In [11]:
requests.get(
    location,
    headers=auth_headers,
).json()
Out[11]:
{'id': 'fdd3d1f2-d7ee-4967-8d3a-919e95befa85',
 'createdTimestamp': 1680599529413,
 'username': 'alice',
 'enabled': True,
 'totp': False,
 'emailVerified': False,
 'disableableCredentialTypes': [],
 'requiredActions': [],
 'notBefore': 0,
 'access': {'manageGroupMembership': True,
  'view': True,
  'mapRoles': True,
  'impersonate': True,
  'manage': True}}

Authenticate the user

To put it all together, let's authenticate as the created user, using the created client, in the created realm.

First, get the token endpoint of the realm:

In [12]:
token_endpoint = requests.get(
    f"{keycloak_root}/realms/{realm}/.well-known/openid-configuration"
).json()["token_endpoint"]
print(token_endpoint)
http://localhost:8642/realms/keldor/protocol/openid-connect/token

Do the token request (here using the password grant for simplicity):

In [13]:
resp = requests.post(
    token_endpoint,
    data={
        "client_id": client_id,
        "username": username,
        "password": password,
        "grant_type": "password",
    }
)
resp.raise_for_status()
resp.json()
Out[13]:
{'access_token': 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJLZG02R3pYT0hfNUNFTjlPVGFGS1RIajUxckRKbG9uZ1FydW4yQ0I1WU8wIn0.eyJleHAiOjE2ODA1OTk4MjksImlhdCI6MTY4MDU5OTUyOSwianRpIjoiODU5MTliOWQtYjQ0Mi00ZThhLWI3NTktYzBjNmQxOGM3NTM2IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4NjQyL3JlYWxtcy9rZWxkb3IiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiZmRkM2QxZjItZDdlZS00OTY3LThkM2EtOTE5ZTk1YmVmYTg1IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiZmFuY3ktY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6Ijk0NTQzMDFmLTdhNDYtNGUwYy1iNTNmLTVkNTIyYTI1YzQxOCIsImFjciI6IjEiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJkZWZhdWx0LXJvbGVzLWtlbGRvciIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJwcm9maWxlIGVtYWlsIiwic2lkIjoiOTQ1NDMwMWYtN2E0Ni00ZTBjLWI1M2YtNWQ1MjJhMjVjNDE4IiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhbGljZSJ9.j_fIy0jmQJF5hrUb_k6Tdf8SdyVwG2lYq8PUF_1M8UbEl2aHp7h1vMMlRh864JA84GoA_EK7CzVyQOrc0Uy9oILfvVpPukSZ9QEgbwjnCslJkLtw71080lGFpoAUa34wcomW6uaRfhf-1ldBtoQMzWuHmScnvSt9moQwTwRKE3xNW_CjEdl2lW3XqH0MOFnbadDI2QRIbnhYdKPGePFXeyDRxuq_8KoSVrU_DVSETxWG70CtUijZ8y3G9PkoYtsCUxD9_P4Ut1DgbQQnKOVbs51zwYMEjyTKizZc-z25C84FlOw6lCGQ1aKd1gkxfwuQp7BmlPuNHOih4lloHxYghg',
 'expires_in': 300,
 'refresh_expires_in': 1800,
 'refresh_token': 'eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIzNDIzOGUyYi1jM2ZhLTQ2NzUtYTY2My05ZWVhMTBlYjY1Y2YifQ.eyJleHAiOjE2ODA2MDEzMjksImlhdCI6MTY4MDU5OTUyOSwianRpIjoiYTFhOWFjOWUtMTQ0Ni00MTNjLWI1YzctYTYxYmRjZjgxZjU3IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4NjQyL3JlYWxtcy9rZWxkb3IiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0Ojg2NDIvcmVhbG1zL2tlbGRvciIsInN1YiI6ImZkZDNkMWYyLWQ3ZWUtNDk2Ny04ZDNhLTkxOWU5NWJlZmE4NSIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJmYW5jeS1jbGllbnQiLCJzZXNzaW9uX3N0YXRlIjoiOTQ1NDMwMWYtN2E0Ni00ZTBjLWI1M2YtNWQ1MjJhMjVjNDE4Iiwic2NvcGUiOiJwcm9maWxlIGVtYWlsIiwic2lkIjoiOTQ1NDMwMWYtN2E0Ni00ZTBjLWI1M2YtNWQ1MjJhMjVjNDE4In0.pubX4yxAk_p0zpb9E2DOX8CpCplqHlbDOoLl4VuYcvM',
 'token_type': 'Bearer',
 'not-before-policy': 0,
 'session_state': '9454301f-7a46-4e0c-b53f-5d522a25c418',
 'scope': 'profile email'}

The end.