KeyCloak x509 Authentication
As a frequent user of multiple forms of IDs such as Smart Cards for Organizations, ECA certificates to validate a personal ideantity, and government IDs such as CACs and PIVs, I want to get smart on how certificate authentication can work.
Getting Started
With any project, the first step is to learn how others have done it. Over at DoD Platform1 a KeyCloak prototype exists which includes x509 flows, custom theming, and custom modules to register/update x509 certificates to a user identity in KeyCloak.
Problem 1: As of this writing, one feature KeyCloak does NOT have is mapping x509 certificates to user identities. The best we can do is manually map a common_name
or email
field to a KeyCloak custom user attribute or ensure that field matches an existing users username or email.
Docker Build
KeyCloak will pull some certificate trust information from the OS or default Java truststore. Unfortunately x509 certificate trust doesn’t use the system trust, you’ll need to provide a custom truststore. In the image below we add our organizations certificate to keycloak AND a custom truststore.
Below DoD is used as a reference as their root and intermediate certificates are publically available as an example. Something interesting about the second bundle is that it contains root and intermediate certificates for common ECA vendors, which is great for me as I’m using an ECA from one of these vendors.
FROM registry.access.redhat.com/ubi9 AS ubi-micro-build
RUN yum update -y && \
yum install -y unzip
RUN curl -sLO "https://dl.dod.cyber.mil/wp-content/uploads/pki-pke/zip/unclass-certificates_pkcs7_DoD.zip" && \
unzip -q unclass-certificates_pkcs7_DoD.zip && \
openssl pkcs7 -inform DER -outform PEM -in certificates_pkcs7_v5_13_dod/certificates_pkcs7_v5_13_dod_der.p7b -print_certs | awk 'BEGIN {c=0;} /BEGIN CERT/{c++} { print > "dod." c ".crt"}' && \
cp dod.*.crt /etc/pki/ca-trust/source/anchors && \
update-ca-trust
FROM registry.redhat.io/ubi9/openjdk-21:1.21-3 AS openjdk-build
USER 0
RUN microdnf update -y && \
microdnf install -y unzip openssl
RUN curl -sLO "https://dl.dod.cyber.mil/wp-content/uploads/pki-pke/zip/unclass-dod_approved_external_pkis_trust_chains.zip" && \
unzip -q "unclass-dod_approved_external_pkis_trust_chains.zip" && \
for i in $(find ./DoD_Approved_External_PKIs_Trust_Chains_v11.1_20240716 -type f -name *.cer); do keytool -importcert -trustcacerts -keystore /truststore.jks -storepass changeit -noprompt -file "${i}" -alias "$(openssl x509 -in ${i} -noout -subject -nameopt multiline | awk -F' = ' '/commonName/ {print $2}')"; done && \
rm -rf "unclass-dod_approved_external_pkis_trust_chains.zip" "DoD_Approved_External_PKIs_Trust_Chains_v11.1_20240716"
FROM quay.io/keycloak/keycloak:26.0
COPY --from=ubi-micro-build /etc/pki /etc/pki
COPY --from=openjdk-build /truststore.jks /opt/keycloak/conf/truststore.jks
Instance and Ingress Configuration
For this, three instances are setup.
-
The Platform1 KeyCloak development instance mentioned earlier via docker-compose
-
The earlier Docker file spun up in K8s exposed via nodeport. We’re going to KISS by increasing complexity one step at a time.
-
The earlier Docker file spun up in K8s exposed via ingress-nginx. This will be harder as mTLS is going to be broken by our ingress controller
Problem 2: Not all ingress controllers are created equal. For x509 authentication the request will some how need to be forwarded from the ingress controller to the keycloak pod either with the x509 attributes being passed or acting as a layer 4 load balancer just passing all web traffic to the pod.
Below is an example of my setup with secrets, certificates, and URLs sanitized.
$ vim keycloak.yaml
---
# Registry pull credentials
apiVersion: v1
data:
.dockerconfigjson: |
eyJhdXRocyI6eyJyZWdpc3RyeS5qZWxseS5kZXYiOnsidXNlcm5hbWUiOiJib3lvYmVqYW1pbiIsInBhc3N3b3JkIjoiZmxhZ3tEYW1uTXlQYXNzd29yZElzT3Blbn0ifX19Cg==
kind: Secret
metadata:
name: regcred
namespace: keycloak
type: kubernetes.io/dockerconfigjson
---
# Exposing KeyCloak right out of this node
apiVersion: v1
kind: Service
metadata:
name: keycloak
namespace: keycloak
labels:
app: keycloak
spec:
ports:
- name: http
port: 8080
targetPort: 8080
- name: https
port: 8443
targetPort: 8443
selector:
app: keycloak
type: LoadBalancer
---
# Moutning Let's Encrypt certs directly
apiVersion: v1
kind: ConfigMap
metadata:
name: keycloak-certs
namespace: keycloak
data:
tls.crt: |
-----BEGIN CERTIFICATE-----
<redacted>
-----END CERTIFICATE-----
tls.key: |
-----BEGIN PRIVATE KEY-----
<redacted>
-----END PRIVATE KEY-----
---
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: registry.jelly.dev/Library/keycloak:26.0
imagePullPolicy: Always
args: ["start-dev"]
env:
- name: KEYCLOAK_ADMIN
value: "admin"
- name: KEYCLOAK_ADMIN_PASSWORD
value: "ctf{WhatsASecret?}"
- name: KC_PROXY
value: "edge"
- name: KC_HTTPS_CERTIFICATE_FILE
value: /secrets/tls.crt
- name: KC_HTTPS_CERTIFICATE_KEY_FILE
value: /secrets/tls.key
- name: KC_HTTPS_CLIENT_AUTH # Required for x509 auth
value: "request"
- name: KC_HTTPS_TRUST_STORE_FILE # Required for x509 auth
value: /opt/keycloak/conf/truststore.jks
- name: KC_HTTPS_TRUST_STORE_PASSWORD # See dockerfile for password
value: "changeit"
- name: KC_METRICS_ENABLED
value: "true"
ports:
- name: http
containerPort: 8080
- name: https
containerPort: 8443
readinessProbe:
httpGet:
path: /realms/master
port: 8080
volumeMounts:
- name: certs
mountPath: /secrets/
readOnly: true
imagePullSecrets:
- name: regcred
volumes:
- name: certs
configMap:
name: keycloak-certs
OpenID Connect Application
For this, GitLab is being used as it can easily connect to multiple identity providers. The instance is GitLab 17.x Omnibus.
Configure multiple OpenID Connect providers
$ sudo vim /etc/gitlab/gitlab.rb
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_allow_single_sign_on'] = 'true'
gitlab_rails['omniauth_sync_email_from_provider'] = 'true'
gitlab_rails['omniauth_sync_profile_from_provider'] = ['openid_connect']
gitlab_rails['omniauth_sync_profile_attributes'] = ['name', 'email']
gitlab_rails['omniauth_block_auto_created_users'] = false
gitlab_rails['omniauth_auto_link_user'] = 'true'
gitlab_rails['omniauth_providers'] = [
{
name: "openid_connect", # Exposed from the cluster
label: "Jelly KeyCloak", # optional label for login button, defaults to "Openid Connect"
icon: "https://www.jelly.dev/1680273762169.jpg",
args: {
name: "openid_connect",
strategy_class: "OmniAuth::Strategies::OpenIDConnect",
scope: ["openid","profile","email"],
response_type: "code",
issuer: "https://keycloak.jelly.dev:8443/realms/jelly-dev",
discovery: true,
client_auth_method: "query",
uid_field: "<uid_field>",
send_scope_to_token_endpoint: "false",
pkce: true,
client_options: {
identifier: "gitlab",
secret: "ctf{DamnAnothersecretExposed}",
redirect_uri: "https://gitlab.jelly.dev/users/auth/openid_connect/callback"
}
}
},
{
name: "openid_connect_2fa", # P1 KeyCloak
label: "P1 KeyCloak", # optional label for login button, defaults to "Openid Connect"
icon: "https://www.jelly.dev/1680273762169.jpg",
args: {
name: "openid_connect_2fa",
strategy_class: "OmniAuth::Strategies::OpenIDConnect",
scope: ["openid","profile","email"],
response_type: "code",
issuer: "https://keycloak-p1.jelly.dev:8443/auth/realms/baby-yoda",
discovery: true,
client_auth_method: "query",
uid_field: "<uid_field>",
send_scope_to_token_endpoint: "false",
pkce: true,
client_options: {
identifier: "dev_00eb8904-5b88-4c68-ad67-cec0d2e07aa6_gitlab", # this is hard coded in https://repo1.dso.mil/big-bang/product/packages/keycloak/-/blob/45d875d97a3dd68cad6c5610f3b535e0d5997278/development/baby-yoda.json#L836
secret: "ctf{DamnAnothersecretExposed}",
redirect_uri: "https://gitlab.jelly.dev/users/auth/openid_connect_2fa/callback"
}
}
},
{
name: "openid_connect_nginx", # KeyCloak run through ingress-nginx
label: "Ingress-Nginx KC", # optional label for login button, defaults to "Openid Connect"
icon: "https://www.jelly.dev/1680273762169.jpg",
args: {
name: "openid_connect_nginx",
strategy_class: "OmniAuth::Strategies::OpenIDConnect",
scope: ["openid","profile","email"],
response_type: "code",
issuer: "https://keycloak.jelly.dev:8443/realms/jelly-dev",
discovery: true,
client_auth_method: "query",
uid_field: "<uid_field>",
send_scope_to_token_endpoint: "false",
pkce: true,
client_options: {
identifier: "gitlab",
secret: "ctf{DamnAnothersecretExposed}",
redirect_uri: "https://gitlab.jelly.dev/users/auth/openid_connect_nginx/callback"
}
}
}
]
Bounce GitLab to apply changes
$ sudo gitlab-ctl reconfigure
Configure KeyCloak
Client
Before the hard part let’s stick with the easy stuff. Configuring a client for GitLab. We have some parameters already set in our config.
Field | Value |
---|---|
Client ID | GitLab |
Name | GitLab |
Valid redirect URIs | https://gitlab.jelly.dev/users/auth/openid_connect/callback |
Valid redirect URIs | https://gitlab.jelly.dev/users/auth/openid_connect_2fa/callback |
Valid redirect URIs | https://gitlab.jelly.dev/users/auth/openid_connect_nginx/callback* |
Client authentication | Off |
Make sure that in the Client/Client Scopes/Evaluate that email
, username
, given name
, nickname
, and any other fields you need are passed.
General Settings
Realm Settings > General
Field | Value |
---|---|
Realm name | jelly |
Display name | Jelly |
User-managed access | On |
User-managed access | On |
Logging
Turn both on and set it to expire after an hour or so
Realm Settings > Events > User events settings
Realm Settings > Events > Admin events settings
Login settings
Configure > Realm Settings > Login
Field | Status |
---|---|
User registration | On |
Forgot password | On |
Remember me | On |
Email as username | Off |
Login with email | On |
Duplicate emails | Off |
Edit username | Off |
Realm Settings > Email
Useful for going through the motions of verifying user emails.
Custom Fields
Realm Settings > User Profile
Field | Status |
---|---|
Attribute Name | usercertificate |
Display name | x509 User Certificate Common Name |
Multivalued | On |
Who can edit | User, Admin |
Who can View | User, Admin |
Authentication Flows
The following flow defined in Authentication is copy/pasted directly from the Platform1 example.
x509/Validate Username Form config
This is not a secure configuration. We should have more bounds and checks such as CRL and policy verification.
Field | Status |
---|---|
Alias | dod-cac |
User Identity Source | Subject’s Common Name |
User mapping method | Custom Attribute Mapper |
A name of the user attribute | usercertificate |
Certificate Policy Validation Mode | Any |
Add to user profile
Realm Settings > User PRofile > JSON Editor
Add the following attribute:
{
"name": "usercertificate",
"displayName": "${usercertificate}",
"validations": {
"length": {
"min": "0",
"max": "120"
}
},
"annotations": {
"inputHelperTextBefore": "Enter the common name of your certificate"
},
"permissions": {
"view": [
"admin",
"user"
],
"edit": [
"admin",
"user"
]
},
"multivalued": true
}
Profit
Now try registering and logging in!
https://keycloak.jelly.dev:8443/realms/jelly-dev/account/
https://www.stigviewer.com/stig/aix_5.3/2014-10-03/finding/V-24331