Keycloak is an identity management solution useful for securing applications in Kubernetes. This post shows how to set up Keycloak and secure an application in Kubernetes with Keycloak. Sample code available at https://github.com/hkiang01/keycloak-demo.
We’re going to see what it takes to minimally get Keycloak to run in Kubernetes. First, we’ll create a namespace called keycloak
kubectl create namespace keycloak
We’ll then get the quickstart manifest from https://github.com/keycloak/keycloak-quickstarts/tree/latest/kubernetes-examples. I’ve made a few edits, namely:
I’ll call mine keycloak.yaml
# keycloak.yaml
apiVersion: v1
kind: Service
metadata:
name: keycloak
namespace: keycloak
labels:
app: keycloak
spec:
ports:
- name: http
port: 8080
targetPort: 8080
selector:
app: keycloak
type: ClusterIP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: keycloak
namespace: keycloak
labels:
app: keycloak
spec:
replicas: 1
selector:
matchLabels:
app: keycloak
template:
metadata:
labels:
app: keycloak
spec:
containers:
- name: keycloak
image: quay.io/keycloak/keycloak:11.0.2
env:
- name: KEYCLOAK_USER
value: "admin"
- name: KEYCLOAK_PASSWORD
value: "admin"
- name: PROXY_ADDRESS_FORWARDING
value: "true"
ports:
- name: http
containerPort: 8080
- name: https
containerPort: 8443
readinessProbe:
httpGet:
path: /auth/realms/master
port: 8080
Let’s apply it
kubectl apply -f keycloak.yaml
Let’s make sure it’s up and running:
kubectl get all -n keycloak
You should see that the pods are all ready. Below, 1 of 1 pods are ready, as indicated by 1/1.
NAME READY STATUS RESTARTS AGE
pod/keycloak-6bc5f6d94c-bdbln 1/1 Running 0 3m25s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/keycloak ClusterIP 10.152.183.91 <none> 8080/TCP 3m26s
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/keycloak 1/1 1 1 3m25s
NAME DESIRED CURRENT READY AGE
replicaset.apps/keycloak-6bc5f6d94c 1 1 1 3m25s
To access the app, we’ll need to port-forward the service:
kubectl -n keycloak port-forward svc/keycloak 8080
You should see output like the following:
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080
We should now be able to access the instance at http://localhost:8080 in our browser. Before we continue, let’s do some cleanup:
kubectl delete -f keycloak.yaml
Keycloak is a stateful application that is backed by a database like Postgres. We’re going to take the above manifest from Installing Keycloak and paste it as a template in a new Helm chart with Postgres as a package dependency. You can use an existing chart, but I think it’s valuable to see what a minimal chart looks like.
Let’s first create a chart called keycloak:
helm create keycloak
This is the file tree of what’s created
├── keycloak
│ ├── charts
│ ├── Chart.yaml
│ ├── templates
│ │ ├── deployment.yaml
│ │ ├── _helpers.tpl
│ │ ├── hpa.yaml
│ │ ├── ingress.yaml
│ │ ├── NOTES.txt
│ │ ├── serviceaccount.yaml
│ │ ├── service.yaml
│ │ └── tests
│ │ └── test-connection.yaml
│ └── values.yaml
We can get rid of the following in templates/:
cd keycloak/templates/
rm -rf hpa.yaml tests/
Let’s configure the chart to use the keycloak image. We can accomplish this with the follwing in values.yaml at the root of the chart.
# keycloak/values.yaml
image:
repository: quay.io/keycloak/keycloak
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: "11.0.2"
And configure our credentials:
You should never store secrets in plaintext in your repo history. The snippet below is for demonstration purposes only.
# keycloak/values.yaml
username: admin
password: supersecretpassword
You can use Sealed Secrets for Kubernetes and store secrets in encrypted form in your chart’s templates. See Usage to get started.
The container environment variables, ports, and probes will have to be copied over as well.
# keycloak/templates/deployment.yaml
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: KEYCLOAK_USER
value: {{ .Values.username }}
- name: KEYCLOAK_PASSWORD
value: {{ .Values.password }}
- name: PROXY_ADDRESS_FORWARDING
value: "true"
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: https
containerPort: 8443
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /auth/realms/master
port: http
We can now test our chart to make sure we have the same level of access as before. Perform the following command in a directory containing the keycloak/ directory that is the chart we created:
helm -n keycloak upgrade --install keycloak ./keycloak
Port-forward the service:
kubectl -n keycloak port-forward svc/keycloak 8080:80
We should now be able to access the instance at http://localhost:8080 in our browser.
The reason we want to add Postgresql via a Helm dependency is that a lot of the legwork with respect to ensuring availability and persistence is already done for you. There are packages that offer high availability, but here we’ll just go for the standard postgresql package.
Let’s add the dependency:
# keycloak/Chart.yaml
dependencies:
- name: postgresql
version: 9.8.6
repository: https://charts.bitnami.com/bitnami
Pull the dependency:
helm dependency update ./keycloak
You should see output like below:
...Successfully got an update from the "bitnami" chart repository
Update Complete. ⎈Happy Helming!⎈
Saving 1 charts
Downloading postgresql from repo https://charts.bitnami.com/bitnami
Deleting outdated charts
You can now observe the chart itself. We’re going to configure it by continuing to edit the same values.yaml file we used to set the chart’s image to keycloak to configure out secrets
You should never store secrets in plaintext in your repo history. The snippet below is for demonstration purposes only.
# keycloak/values.yaml
postgresql:
postgresqlUsername: postgres
postgresqlPassword: secretpassword
postgresqlDatabase: keycloak
service:
port: 5432
Use a Kubernetes Secret to define your credentials and point postgresql.existingSecret
in values.yaml to it.
You can use Sealed Secrets for Kubernetes and store secrets in encrypted form in your chart’s templates.
See Usage to get started.
Now we have to make Keycloak talk to Postgres.
# keycloak/templates/deployment.yaml
env:
- name: KEYCLOAK_USER
value: {{ .Values.username }}
- name: KEYCLOAK_PASSWORD
value: {{ .Values.password }}
- name: PROXY_ADDRESS_FORWARDING
value: "true"
- name: DB_VENDOR
value: postgres
- name: DB_ADDR
value: {{ include "keycloak.fullname" . }}-postgresql
- name: DB_PORT
value: {{ .Values.postgresql.service.port | quote }}
- name: DB_DATABASE
value: {{ .Values.postgresql.postgresqlDatabase }}
- name: DB_USER
value: {{ .Values.postgresql.postgresqlUsername }}
- name: DB_PASSWORD
value: {{ .Values.postgresql.postgresqlPassword }}
Deploy the chart:
helm -n keycloak upgrade --install keycloak ./keycloak
You should see output like the following:
Release "keycloak" has been upgraded. Happy Helming!
NAME: keycloak
LAST DEPLOYED: Sun Oct 25 01:02:10 2020
NAMESPACE: keycloak
STATUS: deployed
REVISION: 5
TEST SUITE: None
NOTES:
1. Get the application URL by running these commands:
export POD_NAME=$(kubectl get pods --namespace keycloak -l "app.kubernetes.io/name=keycloak,app.kubernetes.io/instance=keycloak" -o jsonpath="{.items[0].metadata.name}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace keycloak port-forward $POD_NAME 8080:80
By following the instructions from the output above, you should be able to access an instance of keycloak backed by Postgres.
To validate that your instance is backed by Postgres, you can tail the logs of the Keycloak pod:
kubectl -n keycloak logs -f -l=app.kubernetes.io/name=keycloak
You should see output like below:
=========================================================================
Using PostgreSQL database
=========================================================================
...
Keycloak has Realms:
Keycloak supports multiple tenancy where all users, clients, and so on are grouped in what is called a realm. Each realm is independent of other realms.
By default, Keycloak sets up a “Master” Realm that can only be used to create other Realms. Let’s create a Realm and call it “demo” by following the instructions outlined in Creating a realm. Once the Realm is created you should see something like this:
In order for an application or service to utilize Keycloak it has to register a client in Keycloak.
We’ll have to create an OIDC client. Let’s do so by following the instructions outlined in OIDC Clients. It’s a good idea to enter a “Root URL” as the resultant Client Settings page will fill out all the nuanced fields for you.
In Client Settings, scroll down and ensure “Direct Access Grants Enabled” is toggled to “On” and change the “Access Type” to confidential, then click “Save”. You should now see a “Credentials” tab appear in the client like below (see Confidential Client Credentials):
Note the value in “Secret”, we’re going to use that when performing our password grant.
Next, create a user by following the instructions outlined in Creating a user.
Then give the user a password by filling out the form under the “Credentials” tab for the user.
For demonstration purposes, I’m opting to flip the “Temporary” switch to “Off”.
You should then be able to log in by following the instructions outlined in Logging into the account console. You should see something like this:
Verify that your user has access to your application by observing your client that you created in the list of “Applications”
Note that “myapp” is in the above list of Applications.
Now make a Direct Grant as described in the Resource Owner Password Credentials. Here is an example:
curl --request POST 'https://keycloak.harrisonkiang.com/auth/realms/demo/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'username=johndoe' \
--data-urlencode 'password=johndoe' \
--data-urlencode 'client_id=myapp' \
--data-urlencode 'client_secret=90a47b27-6de6-43e9-a300-4eb02f18b447' \
--data-urlencode 'grant_type=password'
The username
and password
are that of the user, the client_id
is the name of the client, and the client_secret
is the value of “Secret” in the “Credentials” tab of the client.
You should get back a response with an access token, refresh token, etc.
We’re going to use Keycloak Gatekeeper. It will be set up as a sidecar in the same pod containing our application.
Let’s first create an application:
helm create app
The following files will be created:
app
├── charts
├── Chart.yaml
├── templates
│ ├── deployment.yaml
│ ├── _helpers.tpl
│ ├── hpa.yaml
│ ├── ingress.yaml
│ ├── NOTES.txt
│ ├── serviceaccount.yaml
│ ├── service.yaml
│ └── tests
│ └── test-connection.yaml
└── values.yaml
We can get rid of the following in templates/:
cd app/templates/
rm -rf hpa.yaml tests/
We’re going to Configure a Pod to Use a ConfigMap to store our Keycloak.
# app/templates/configmap.yaml
data:
keycloak-gatekeeper.yaml: |
# is the url for retrieve the OpenID configuration - normally the <server>/auth/realm/<realm_name>
discovery-url: {{ .Values.keycloak.discoveryUrl }}
# the client id for the 'client' application
client-id: {{ .Values.keycloak.clientId }}
# the secret associated to the 'client' application
client-secret: {{ .Values.keycloak.clientSecret }}
# enforces the cookie to be secure"`
secure-cookie: false
# the interface definition you wish the proxy to listen, all interfaces is specified as ':<port>', unix sockets as unix://<REL_PATH>|</ABS PATH>
listen: :3000
# whether to enable refresh tokens
enable-refresh-tokens: true
# the redirection url, essentially the site url, note: /oauth/callback is added at the end
redirection-url: http://{{ get $hosts "host"}}
# the encryption key used to encode the session state
encryption-key: "{{ randAlphaNum 32 }}"
# the upstream endpoint which we should proxy request
upstream-url: http://127.0.0.1:80
See Configuration options for details on each config. We’ll need to set some of the values we use in the above config.
# app/values.yaml
keycloak:
clientId: app
clientSecret: 90a47b27-6de6-43e9-a300-4eb02f18b447
discoveryUrl: https://keycloak.harrisonkiang.com/auth/realms/demo
The client secret is relatively safe to share, as the user will require valid credentials to access the protected resources in your app, which is the whole point of securing your app in the first place.
I’m using a TLS secret (see below):
# app/values.yaml
ingress:
tls:
- secretName: harrisonkiang-dot-com-wildcard-tls
The above TLS secret is not provided in the code samples at https://github.com/hkiang01/keycloak-demo. You can configure your own TLS secrets or else rely on the Default SSL Certificate created by NGINX Ingress Controller.
Of course we’ll need to use said configmap:
# app/templates/deployment.yaml
containers:
- name: gatekeeper
image: quay.io/louketo/louketo-proxy:1.0.0
args:
- --config=/etc/config/keycloak-gatekeeper.yaml
volumeMounts:
- name: gatekeeper-config
mountPath: /etc/config
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: gatekeeper
containerPort: 3000
protocol: TCP
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 80
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: gatekeeper-config
configMap:
name: {{ include "app.fullname" . }}-gatekeeper
Let’s deploy
helm -n keycloak upgrade --install app app
Now let’s make sure everything is ready
helm -n keycloak get manifest app | kubectl -n keycloak get -f -
NAME SECRETS AGE
serviceaccount/app 1 29m
NAME DATA AGE
configmap/app-gatekeeper 1 29m
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/app ClusterIP 10.152.183.125 <none> 80/TCP 29m
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/app 1/1 1 1 29m
NAME CLASS HOSTS ADDRESS PORTS AGE
ingress.networking.k8s.io/app <none> app.harrisonkiang.com 127.0.0.1 80, 443 29m
and that the pod is up
kubectl -n keycloak get pod -l=app.kubernetes.io/instance=app
NAME READY STATUS RESTARTS AGE
app-78d9484d86-8hvsd 2/2 Running 0 31m
2/2 means we’re good to go. Now you should be able to almost log in as normal.
You’ll find that after trying to log in your app via the browser you won’t get what you expect.
This is due to the fact that you have to manually add your client_id
to the audience field aud
of the access token per this SO post, the important bit of which is in the snippet below:
Configure audience in Keycloak
- Add realm or configure existing
- Add client my-app or use existing
- Goto to the newly added “Client Scopes” menu [1]
- Add Client scope ‘good-service’
- Within the settings of the ‘good-service’ goto Mappers tab
- Create Protocol Mapper ‘my-app-audience’
- Name: my-app-audience
- Choose Mapper type: Audience
- Included Client Audience: my-app
- Add to access token: on
- Configure client my-app in the “Clients” menu
- Client Scopes tab in my-app settings
- Add available client scopes “good-service” to assigned default client scopes
After following the above you should be able to successfully acces your application!
To understand the flow, let’s examine how the traffic travels from the outside to the app with and without Keycloak.
Here’s what it looks like with Keycloak
All traffic is gated via the gatekeeper, which ensures all requests are secure. If the credentials are invalid or expired, then the user will be redirected to log back in via Keycloak.
If we look closer at the service, we’ll see that the named targetPort
points to the gatekeeper
container in the pod:
# app/templates/service.yaml
- port: {{ .Values.service.port }}
targetPort: gatekeeper # <--- LOOK HERE
protocol: TCP
name: http