SSO with Keycloak, oauth2proxy and Ansible

Yannik Schmidt
6 min readDec 22, 2023

--

Ansible is a Infrastructure-as-Code definition language, Keycloak is a OpenID-Connect provider, authentication broker and can handle user federation.

In this article, I will describe how to create a scalable Keycloak Single Sign-On (SSO) setup, entirely modeled in Ansible. To follow along, you will need a basic understanding of Docker(-compose), Ansible, proxying, Linux and OpenID-Connect itself.

Ansible Basics

I assume you already have some basic understanding of Ansible, but in general you need the following directories:

# file templates Keycloak
mkdir ./roles/keycloak/templates

# Ansible tasks keycloak
mkdir ./roles/keycloak/tasks

# file templates deployments
mkdir ./roles/deployments/templates

# Ansible tasks deployments
mkdir ./roles/deployments/tasks

# variables
mkdir ./group_vars/

If you want an Ansible vault for your secrets instead of using group_vars/all.yaml, refer to the Ansible vault documentation.

Deploying Keycloak

OIDC requires https, meaning you require a TLS setup in front of your Keycloak. The easiest way to do this is with nginx and Let’s Encrypt. The outward facing https-address of Keycloak will be referenced as {{ keycloak_address }} from now on.

Let’s start with the Keycloak compose file. If you are going with Ansible this should go into roles/keycloak/templates and be called keycloak.yaml.

---
version: '3.3'
services:
keycloak:
container_name: keycloak-container
command: start --hostname-strict=false --log-level=WARNING
image: quay.io/keycloak/keycloak:23.0.3 # <- version as of dez 2023
environment:
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD={{ keycloak_admin_password }}
- PROXY_ADDRESS_FORWARDING=true
- KC_PROXY=edge
- KC_LOG_LEVEL=ALL
- KC_DB_URL_HOST=postgres
- KC_DB_USERNAME=keycloak
- KC_DB_PASSWORD={{ keycloak_postgres_password }}
- KC_HEALTH_ENABLED=true
- KC_METRICS_ENABLED=true
- KEYCLOAK_LOGLEVEL=WARN
restart: unless-stopped
ports:
- 5050:8080
depends_on:
- postgres
postgres:
container_name: postgres-container
image: postgres:15.1
environment:
- POSTGRES_DB=keycloak
- POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password
- POSTGRES_USER=keycloak
restart: unless-stopped
secrets:
- postgres_password
volumes:
- /data/keycloak-postgres/:/var/lib/postgresql/data

secrets:
postgres_password:
file: postgres_password

...

There is a lot going on here, first we have to define the variables/secrets referenced in the compose file, those being keycloak_admin_password and keycloak_postgres_password, which we will also need for the secrets-file. Secondly we have the volume, which mounts a filesystem-path of the host into the postgres container. Now this isn't strictly necessary if all of your configurations are modeled in Ansible, but it also means, you don't have to run your playbook, every time your recreate the container.

For demonstration purposes we will define these secrets in group_vars/all.yaml, but generally secrets should be defined in a vault and only for individual hosts:

# group_vars/all.yaml
keycloak_admin_password=adminpassword
keycloak_postgres_password=pgpassword

Finally we need to define Ansible tasks to:

  • install the necessary packages on the system
  • create the volume-data directory in /data/
  • create the target directory for the docker-compose deployment
  • template and copy the compose file to remote
  • deploy the compose file
  • wait for keycloak to boot before starting configuration

As an Ansible-tasks file it should look like this:

# roles/keycloak/tasks/main.yaml
- name: Install docker-compose
package:
name:
- docker-compose # should include the systems container-manager
state: present

- name: Create data-dir
file:
name: /data/
state: directory

- name: Create keycloak psql volume-mount
file:
name: /data/keycloak-postgres/
state: directory

- name: Create compose directory keycloak
file:
name: "/opt/keycloak/"
state: directory

- name: Copy compose templates keycloak
template:
src: "keycloak.yaml"
dest: "/opt/keycloak/"

- name: Copy compose postgres secret file
copy:
content: "{{ keycloak_postgres_password }}"
dest: "/opt/keycloak/postgres_password"

- name: Deploy compose templates
community.docker.docker_compose:
project_src: "/opt/keycloak/"
pull: true
files:
- "keycloak.yaml"

- name: Check/Wait for Keycloak to be up
uri:
url: https://keycloak.atlantishq.de/health
method: GET
return_content: yes
status_code: 200
body_format: json
register: result
until: result.status == 200 and result.json.status == "UP"
retries: 10
delay: 20
check_mode: false

Create a OIDC-Client

Now lets secure an application, which does not support OIDC by itself with oauth2proxy. Traeffic also supports this functionality if you are already using it.

To create a working setup we need to:

  • create an OIDC-client in Keycloak
  • configure & deploy an oauth2proxy container in front of our application

We will be using the Ansible module keycloak-client as a local_action.

Let’s define the necessary variables for our client first and build the Ansible tasks in a way, so multiple clients can be deployed easily later.

Please note, that the the secrets need to be exactly the given lengths, you should create those with the command given (provided by the package of the same name), something you can do in Ansible itself, but I would configure them statically as variables.

# this goes into group_vars/all.yaml
keycloak_clients:
client_name:
party_secret: "$(pwgen -s 16 -n 1)"
client_id: name_of_your_client
client_secret: "$(pwgen -s 32 -n 1)"
redirect_uris:
- "https://target_subdomain.example.com/*"
description: "A description only displayed in Keycloak"
keycloak_id: "00000000-0000-0000-0000-000000000001"
groups:
# groups: "group1,group2"
master_address: "https://target_subdomain.example.com"
skips:
- "/logo/light.svg"
  • groups optionally defines a list of groups a user must be part of, to be allowed to continue
  • redirect_uris defines a list of allowed redirection URLs, meaning the URLs you will be redirected to, after logging in. This is important, because pages can request arbitrary redirect URLs when redirecting to the login.
  • master_address defines a default for redirection, if none is given as part of the login request
  • skips optionally defines a list of paths, which will be forwarded without authentication, you may use those for health-endpoints, icons or other unprivileged pages

Now we use the Ansible-list keycloak_clients in a task to create and update those clients on our deployment like this:

# this goes into roles/keycloak/task/main.yaml, after the waiting task
- name: Create Keycloak Clients
local_action:
module: keycloak_client
auth_client_id: admin-cli
auth_keycloak_url: https://keycloak.atlantishq.de/
auth_realm: master
auth_username: admin
auth_password: "{{ keycloak_admin_password }}"
state: present
realm: master
client_id: '{{ keycloak_clients[item]["client_id"] }}'
id: '{{ keycloak_clients[item]["keycloak_id"] }}'
name: '{{ keycloak_clients[item]["client_id"] }}'
description: '{{ keycloak_clients[item]["description"] }}'
enabled: True
client_authenticator_type: client-secret
public_client: false
secret: '{{ keycloak_clients[item]["client_secret"] }}'
authorization_services_enabled: true
service_accounts_enabled: true
redirect_uris: '{{ keycloak_clients[item]["redirect_uris"] }}'
web_origins: '{{ keycloak_clients[item]["redirect_uris"] }}'
frontchannel_logout: False
protocol: openid-connect

# >> explanation below << #
protocol_mappers:
- config:
accesss.token.claim: true
claim.name: "groups"
id.token.claim: true
userinfo.token.claim: true
full.path: false
id: "{{ keycloak_clients[item]['keycloak_id'] | regex_replace('^(?P<X>.{2})(.)', '\\g<X>' ~ '1') }}"
consentRequired: false
protocol: "openid-connect"
protocolMapper: "oidc-group-membership-mapper"
name: "client-group-mapper"
- config:
included.client.audience: '{{ keycloak_clients[item]["client_id"] }}'
id.token.claim: false
access.token.claim: true
id: "{{ keycloak_clients[item]['keycloak_id'] | regex_replace('^(?P<X>.{2})(.)', '\\g<X>' ~ '2') }}"
consentRequired: false
protocol: "openid-connect"
protocolMapper: "oidc-audience-mapper"
name: "aud-mapper-client"
with_items: "{{ keycloak_clients.keys() | list }}"

The task iterates over the keycloak_clients list we defined in the previous step in group_vars/all.yaml. The first part should be pretty self-explanatory. But what about the second part?

Those are so called OIDC-scope claims, in short, they define information on the OIDC-server, which should be passed on to the client. In our case we are passing two special information:

  • the groups the user is part of
  • the intended “audience” aka the name of the client we are authenticating with (something required internally by oauth2proxy)

The regex_replace might seem strange, but it only replaces a single number in the ID with a 1 and 2 respectively, to create unique associated IDs for every client. Meaning:

# keycloak client base ID
00000000-0000-0000-0000-000000000001
# becomes
00100000-0000-0000-0000-000000000001
# and
00200000-0000-0000-0000-000000000001

..you can later use similar strategies for managing IDs of more complicated mappers or claims.

Create a oauth2proxy-Deployment

With these preparations done, we can now finally deploy an oauth2proxy-container with an application behind it. To do this, first create a compose template again (note the UPSTREAM address and port, which has to be the port and address, the target application is running on):

version: "3.7"
services:
oauth2-proxy-{{ item }}:
image: bitnami/oauth2-proxy:7.3.0
depends_on:
- redis
restart: always
command:
{% if keycloak_clients[item].get("skips") %}
{% for route in keycloak_clients[item].skips %}
- --skip-auth-route
- {{ route }}
{% endfor %}
{% endif %}
- --http-address
- 0.0.0.0:{{ services[item].port }}
ports:
- {{ services[item].port }}:{{ services[item].port }}
environment:
OAUTH2_PROXY_SCOPE: openid email profile
OAUTH2_PROXY_UPSTREAMS: http://{{ ansible_default_ipv4.address }}:5000
OAUTH2_PROXY_EMAIL_DOMAINS: '*'
OAUTH2_PROXY_PROVIDER: keycloak-oidc
OAUTH2_PROXY_PROVIDER_DISPLAY_NAME: "Display Name"
OAUTH2_PROXY_REDIRECT_URL: "{{ keycloak_clients[item].master_address }}/oauth2/callback"
OAUTH2_PROXY_OIDC_ISSUER_URL: "https://{{ keycloak_address }}/realms/master"
OAUTH2_PROXY_CLIENT_ID: "{{ keycloak_clients[item].client_id }}"
OAUTH2_PROXY_CLIENT_SECRET: "{{ keycloak_clients[item].client_secret }}"

{% if keycloak_clients[item].groups %}
OAUTH2_PROXY_ALLOWED_GROUPS: {{ keycloak_clients[item].groups }}
{% endif %}

OAUTH2_PROXY_OIDC_EMAIL_CLAIM: sub
OAUTH2_PROXY_SET_XAUTHREQUEST: "true"

OAUTH2_PROXY_SESSION_STORE_TYPE: redis
OAUTH2_PROXY_REDIS_CONNECTION_URL: redis://redis

OAUTH2_PROXY_COOKIE_REFRESH: 15m
OAUTH2_PROXY_COOKIE_NAME: SESSION
OAUTH2_PROXY_COOKIE_SECRET: "{{ keycloak_clients[item].party_secret }}"

OAUTH2_PROXY_REVERSE_PROXY: "true"
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true"

OAUTH2_PROXY_WHITELIST_DOMAIN: "{{ keycloak_address }}"

# as part of the compose file we also need a session storage
redis:
image: redis:7.2.4-alpine
restart: always
volumes:
- cache:/data

# no mounts, since session storage is transitory
volumes:
cache:
driver: local

..and the deploy it with Ansible tasks:

# this goes into roles/deployments/tasks/main.yaml
- name: Create opt-dir
file:
name: /opt/
state: directory

- name: OAuth2Proxy directories
file:
path: "/opt/oauth2proxy/{{ item }}/"
state: directory
recurse: yes
with_items:
- client_name

- name: Deploy OAuth2Proxy compose files
template:
src: oauth-standalone-docker-compose.yaml
dest: "/opt/oauth2proxy/{{ item }}/docker-compose.yaml"
with_items:
- client_name

- name: Deploy OAuth2Proxy
community.docker.docker_compose:
project_src: /opt/oauth2proxy/{{ item }}/
pull: true
with_items:
- client_name

Now if you want to test it, you can try it with your application or by running a simple web-server on the correct port like this:

docker run -p 5000:80 nginx

Top-Level Ansible Playbook

If you want to deploy the whole thing on your server, we need to define some more Ansible overhead. All of the following files go into the root directory of your Ansible project (meaning the same directory the group_vars and roles-directories reside in).

Host.ini

A file describing a list of hosts, for example:

# host.ini
[keycloak]
192.168.122.1
[deployments]
192.168.122.1

playbook.yaml

A playbook file describing, which roles to run on which host:

- hosts: keycloak
roles:
- keycloak

- hosts: deployments
roles:
- deployments

With these files ready we can finally run the whole thing with:

ansible-playbook -i hosts.ini playbook.yaml --diff

Originally published at https://yannikschmidt.com on December 22, 2023.

--

--

Yannik Schmidt

Python programmer at heart, Linux Expert at work, GameDev on my good days, web developer on the bad days.