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.

  1. The Platform1 KeyCloak development instance mentioned earlier via docker-compose

  2. The earlier Docker file spun up in K8s exposed via nodeport. We’re going to KISS by increasing complexity one step at a time.

  3. 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

Email

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.

Auth Flow

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