Using Keycloak with OpenStack

⚠️
We are not responsible for any security incidents that might arise from following this guide. Always make sure you understand what is going on and refer to the official documentation if you are in doubt.

While the OpenStack Identity Service (Keystone) has rudimentary support for TOTP-based 2FA, the default web interface (Horizon) does not currently support it. In this article, I will outline how to use Keycloak as an identity provider (IdP) with Keystone and Horizon.

Keystone and Keycloak support two federation protocols: OpenID Connect and SAML2. Here we have chosen to use SAML2. The official documentation contains a useful diagram outlining the entire authentication process.

SAML2.0 WebSSO Authentication Flow

In the context of SAML2.0 Keystone plays the role of Service Provider (SP) while Keycloak is the Identity Provider (IdP). Each component has a (globally) unique Entity ID. For Keystone this value can be chosen freely (we will choose https://keystone.example.com) while the Keycloak Entity ID needs to be obtained from the SAML2.0 metadata endpoint, we'll get to that later.

While most instructions should work regardless of the distribution used, I'm running Keystone on Debian Buster and Horizon on Bullseye.

This guide consists of three steps:

  1. Install and configure a Keycloak instance
  2. Configure Keystone and Shibboleth
  3. Configure Horizon

Part 1: Keycloak

In this section, I'll describe my Keycloak installation. Please refer to the Keycloak documentation for details on running it in production.

Setting up Keycloak

First, make sure all required packages are installed.

apt-get install unzip openjdk-11-jdk mariadb-server apache2
Install required packages

Then install Keycloak to /opt/keycloak.

wget https://github.com/keycloak/keycloak/releases/download/21.0.2/keycloak-21.0.2.zip
unzip keycloak-21.0.2.zip

mkdir -p /opt
mv keycloak-21.0.2 /opt/keycloak

/opt/keycloak/bin/kc.sh build --db mariadb

Create a database for Keycloak to use.

echo "CREATE DATABASE IF NOT EXISTS keycloak; CREATE USER IF NOT EXISTS keycloak IDENTIFIED BY 'CHANGE_ME'; GRANT ALL ON keycloak.* TO keycloak;" | mysql

Then edit /opt/keycloak/conf/keycloak.conf and change the following options:

db=mariadb
db-username=keycloak
db-password=CHANGE_ME
db-url=jdbc:mariadb://localhost/keycloak

proxy=edge
hostname-url=https://keycloak.example.com/

Start Keycloak once manually to make sure the database has been configured.

KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD=CHANGE_ME /opt/keycloak/bin/kc.sh start

Now we want to make sure Keycloak starts automatically by creating a new systemd service at /usr/lib/systemd/system/keycloak.service:

[Unit]
Description=keycloak service
After=network.service

[Service]
ExecStart=/opt/keycloak/bin/kc.sh start --log=file --log-file=/var/log/keycloak.log
PIDFile=/var/run/keycloak.pid

[Install]
WantedBy=multi-user.target
SystemD Keycloak Service

Note: If you are debugging SAML2.0 issues, you can enable SAML2.0 logging by changing the ExecStart line.

ExecStart=/opt/keycloak/bin/kc.sh start --log=file --log-file=/var/log/keycloak.log --log-level=info,org.keycloak.saml:debug

Enable the service.

systemctl enable keycloak

Keycloak Reverse Proxy

While it is possible to expose Keycloak directly to the internet, I've chosen to firewall off the default port (8080) and instead expose it through an Apache reserve proxy.

First, make sure the required Apache modules are enabled.

a2enmod headers
a2enmod proxy
a2enmod proxy_http

Then, set up your virtual host configuration as follows.

<VirtualHost keycloak.ocstro.net:443>
        ServerAdmin webmaster@localhost

        ProxyPreserveHost On
        ProxyPass / http://localhost:8080/ nocanon
        ProxyPassReverse / http://localhost:8080/
        ProxyRequests Off
        
        RequestHeader set X-Forwarded-Proto "https"
        RequestHeader set X-Forwarded-Port "443"        

        ErrorLog ${APACHE_LOG_DIR}/error.log
        CustomLog ${APACHE_LOG_DIR}/access.log combined

        ServerName keycloak.example.com
        
        # Insert your SSL configuration here
</VirtualHost>
Keycloak Apache Reverse Proxy

Realm setup

A single Keycloak server supports multiple realms. Each realm has its own users, groups, and clients. To keep our OpenStack users separate from the rest of our Keycloak instance, we begin by creating a new realm.

You can use any name for your realm, but keep in mind the IdP Entity ID will contain this name. For example if you choose OpenStack as your realm name, the Entity ID will become:

https://keycloak.example.com/realms/OpenStack

You can verify your Entity ID by going to Realm Settings and accessing the SAML 2.0 Identity Provider Metadata under Endpoints in the General settings.

Applications that want to use Keycloak for authentication must be registered in the system as a client. We, therefore, create a new client for our Keystone instance. Select Clients from the sidebar and click Create Client.

The type must be set to SAML, and the Client ID must match the Keystone Entity ID. You can freely choose a (globally) unique Entity ID as long as you later configure Keystone to use that same ID. Here we'll use "https://keystone.example.com". The name of client can be chosen freely.

Once created, open the client and make sure the following settings are set:

  • Valid redirect URIs
    https://keystone.example.com:5000/Shibboleth.sso/SAML2/POST

Then select Authentication from the sidebar, open the browser flow and change OTP from Conditional to Required.

Creating a user

Select Users from the sidebar and click Add user. The username should match the Keystone username.

Part 2: Keystone

The SAML2.0 authentication flow is handled by an Apache module called Shibboleth. Keystone itself just receives the REMOTE_USER parameter with the name of the currently logged in Keycloak user from Shibboleth and has nothing to do with the actual authentication.

Setup Keystone resources

When a user logs in using Keycloak our the SAML2.0 message contains a unique user identifier. This can be the email address, username, or any other attribute associated with the user in Keycloak. The Shibboleth module extracts this user identifier (see below) and passes it to Keystone through the REMOTE_USER variable.

Because Keystone does not know about Keycloak users, it needs to be told how to map remote (Keycloak) to local (Keystone) identities. Start by creating a temporary file that contains the mapping rules, we'll call it rules.json. The mapping below takes the value from REMOTE_USER and finds a local Keystone user with that name.

[
    {
        "local": [
            {
                "user": {
                    "name": "{0}",
                    "type": "local",
                    "domain": {
                        "id": "default"
                    }
                }
            }
        ],
        "remote": [
            {
                "type": "REMOTE_USER"
            }
        ]
    }
]
OpenStack Mapping (rules.json)

Important: we map to existing Keystone users by specifying the type as local. If this is omitted a new user is created in the federated domain of Keystone.

Now create a new mapping using the OpenStack command line tool. The name of the mapping is arbitrary, here we'll name it saml_mapping.

openstack mapping create saml_mapping --rules rules.json

Then, create a new identity provider, we'll name it keycloak_saml. The remote-id argument should be set to the Keycloak Entity ID.

openstack identity provider create keycloak_saml --remote-id https://keycloak.example.com/realms/OpenStack --domain default 

Finally create a new federation protocol, we'll name it saml2, using the following command.

openstack federation protocol create saml2 --mapping saml_mapping --identity-provider keycloak_saml

Once that is done, we'll need to change the Keystone configuration to allow SAML2.0 based authentication. Also make sure your Horizon dashboard is listed as a trusted_dashboard.

[auth]
# The authentication methods allowed, if you specify "password" then the user can login without 2FA being enforced.
methods = token, saml2

[federation]
remote_id_attribute = Shib-Identity-Provider
trusted_dashboard=https://horizon.example.com/auth/websso/

[saml2]
remote_id_attribute = Shib-Identity-Provider
/etc/keystone/keystone.conf

Depending on your distribution, the file sso_callback_template.html might be missing from your /etc/keystone folder. The contents of that file are shown below.

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <title>Keystone WebSSO redirect</title>
  </head>
  <body>
     <form id="sso" name="sso" action="$host" method="post">
       Please wait...
       <br/>
       <input type="hidden" name="token" id="token" value="$token"/>
       <noscript>
         <input type="submit" name="submit_no_javascript" id="submit_no_javascript"
            value="If your JavaScript is disabled, please click to continue"/>
       </noscript>
     </form>
     <script type="text/javascript">
       window.onload = function() {
         document.forms['sso'].submit();
       }
     </script>
  </body>
</html>
/etc/keystone/sso_callback_template.html

Shibboleth - Apache

💡
The actual SAML flow is handled by an Apache module. There are two options: Shibboleth and Mellon. We initially wrote this guide based on Shibboleth. Looking back on our experience, we found Mellon easier to configure to our needs.

Now Keystone knows about our Identity Provider we'll need to configure Apache to use Shibboleth for authentication. If you would like to use Mellon, see Appendix B below for details.

First, install and enable the Apache Shibboleth module.

apt-get install libapache2-mod-shib
a2enmod shib
Install and enable the shib Apache module

Then, change your Apache configuration to require valid authentication when accessing the following endpoints:

/v3/OS-FEDERATION/identity_providers/keycloak_saml/protocols/saml2/auth

/v3/auth/OS-FEDERATION/websso/saml2

/v3/auth/OS-FEDERATION/identity_providers/keycloak_saml/protocols/saml2/websso
Relevant authentication endpoints

Here, keycloak_saml and saml2 refer to the identity provider and federation protocol you've created in the previous step.

Listen 5000

<VirtualHost keystone.example.com:5000>
    SSLEngine on
    SSLHonorCipherOrder on
    # Your SSL configuration here


    WSGIDaemonProcess keystone-public processes=5 threads=1 user=keystone group=keystone display-name=%{GROUP}
    WSGIProcessGroup keystone-public
    WSGIScriptAlias / /usr/bin/keystone-wsgi-public
    WSGIApplicationGroup %{GLOBAL}
    WSGIPassAuthorization On
    ErrorLogFormat "%{cu}t %M"
    ErrorLog /var/log/apache2/keystone.log
    CustomLog /var/log/apache2/keystone_access.log combined

    <Directory /usr/bin>
        Require all granted
    </Directory>

    Alias /identity /usr/bin/keystone-wsgi-public
    
    <Location /identity>
        SetHandler wsgi-script
        Options +ExecCGI

        WSGIProcessGroup keystone-public
        WSGIApplicationGroup %{GLOBAL}
        WSGIPassAuthorization On
    </Location>

    <Location /Shibboleth.sso>
        SetHandler shib
    </Location>

    <Location /v3/OS-FEDERATION/identity_providers/keycloak_saml/protocols/saml2/auth>
        AuthType shibboleth
        Require valid-user
        ShibRequestSetting requireSession 1
        ShibExportAssertion Off
    </Location>

    <Location /v3/auth/OS-FEDERATION/websso/saml2>
        Require valid-user
        AuthType shibboleth
        ShibRequestSetting requireSession 1
        ShibExportAssertion off
        <IfVersion < 2.4>
            ShibRequireSession On
            ShibRequireAll On
        </IfVersion>
    </Location>
    
    <Location /v3/auth/OS-FEDERATION/identity_providers/keycloak_saml/protocols/saml2/websso>
        Require valid-user
        AuthType shibboleth
        ShibRequestSetting requireSession 1
        ShibExportAssertion off
        <IfVersion < 2.4>
            ShibRequireSession On
            ShibRequireAll On
        </IfVersion>
    </Location>
</VirtualHost>

Shibboleth

Now Apache is configure to use Shibboleth, we need to configure Shibboleth itself.

We begin by creating a new private key for Shibboleth to use when signing authentication requests:

cd /etc/shibboleth
shib-keygen -y 1

Then, download the Keycloak metadata to a local file:

wget https://keycloak.example.com/realms/OpenStack/protocol/saml/descriptor -O /etc/shibboleth/keycloak-metadata.xml

Next, we configure Shibboleth by editing /etc/shibboleth/shibboleth2.xml. Make sure the entityID attributes are set correctly and the metadata provider points to the Keycloak SAML2.0 metadata endpoint.

<SPConfig xmlns="urn:mace:shibboleth:3.0:native:sp:config"
    xmlns:conf="urn:mace:shibboleth:3.0:native:sp:config"
    clockSkew="180">

    <OutOfProcess tranLogFormat="%u|%s|%IDP|%i|%ac|%t|%attr|%n|%b|%E|%S|%SS|%L|%UA|%a" />

    <ApplicationDefaults entityID="https://keystone.example.com" signing="true" encryption="false"
        REMOTE_USER="nameID"
        cipherSuites="DEFAULT:!EXP:!LOW:!aNULL:!eNULL:!DES:!IDEA:!SEED:!RC4:!3DES:!kRSA:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1">

        <Sessions lifetime="28800" timeout="3600" relayState="ss:mem"
                  checkAddress="false" handlerSSL="true" cookieProps="https">

            <SSO entityID="https://keycloak.example.com/realms/OpenStack">SAML2</SSO>
            <Logout>SAML2 Local</Logout>
            <LogoutInitiator type="Admin" Location="/Logout/Admin" acl="127.0.0.1 ::1" />
            <Handler type="MetadataGenerator" Location="/Metadata" signing="false"/>
            <Handler type="Status" Location="/Status" acl="127.0.0.1 ::1"/>
            <Handler type="Session" Location="/Session" showAttributeValues="false"/>
            <Handler type="DiscoveryFeed" Location="/DiscoFeed"/>
        </Sessions>

        <Errors supportContact="root@localhost"
            helpLocation="/about.html"
            styleSheet="/shibboleth-sp/main.css"/>

        <MetadataProvider type="XML" url="https://keycloak.example.com/realms/OpenStack/protocol/saml/descriptor" backingFile="keycloak-metadata.xml" />

        <!-- Map to extract attributes from SAML assertions. -->
        <AttributeExtractor type="XML" validate="true" reloadChanges="false" path="attribute-map.xml"/>
        <AttributeFilter type="XML" validate="true" path="attribute-policy.xml"/>

        <!-- Simple file-based resolvers for separate signing/encryption keys. -->
        <CredentialResolver type="File"
            key="sp-key.pem" certificate="sp-cert.pem" />
                <!--<CredentialResolver type="File" use="encryption"
        key="sp-encrypt-key.pem" certificate="sp-encrypt-cert.pem"/>-->

    </ApplicationDefaults>

    <!-- Policies that determine how to process and authenticate runtime messages. -->
    <SecurityPolicyProvider type="XML" validate="true" path="security-policy.xml"/>

    <!-- Low-level configuration about protocols and bindings available for use. -->
    <ProtocolProvider type="XML" validate="true" reloadChanges="false" path="protocols.xml"/>
</SPConfig>
/etc/shibboleth/shibboleth2.xml

The REMOTE_USER attribute contains the id of the attribute Shibboleth passes to Keystone as the remote user (here it is called nameID). The attribute-map.xml file in the Shibboleth configuration directory determines how these attributes are extracted from the SAML2.0 response. In the example below we use the value Keycloak passes as the nameid.

<Attributes xmlns="urn:mace:shibboleth:2.0:attribute-map" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <Attribute name="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" id="nameID"/>
</Attributes>
/etc/shibboleth/attribute-map.xml

Note that nameid is a default SAML2.0 attribute. The unspecified format indicates that the value provided is application defined, in our case it contains the Keycloak username.

Part 3: Horizon

Now both Keycloak and Keystone are properly configured, we need to let Horizon know that the SAML2.0 authentication flow is enabled. The parts of the configuration that are relevant to our setup are listed below.

# Set this to true to enable debug mode
DEBUG = False

ALLOWED_HOSTS = [ "horizon.example.com" ]
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True

OPENSTACK_HOST = "keystone.example.com"
OPENSTACK_KEYSTONE_URL = "https://%s:5000/v3" % OPENSTACK_HOST

WEBSSO_ENABLED = True
WEBSSO_INITIAL_CHOICE = "saml2"

WEBSSO_CHOICES = (
    ("credentials", _("Keystone Credentials")),
    ("saml2", _("Security Assertion Markup Language"))
)
Relevant parts of /etc/openstack-dashboard/local_settings.py

Appendix A: HAProxy

We'll now explore running a reverse proxy that forwards requests to https://keystone.example.com to http://keystone.example.com:5000/.

We've disabled HTTPS on our Keystone server and use the following HAProxy configuration:

frontend http-in
    bind *:443 ssl crt /path/to/privkey.pem
    mode http
    redirect scheme https if !{ ssl_fc }
    use_backend keystone

backend keystone
    server keystone1 keystone.example.com:5000
    mode http
    http-request set-header X-Forwarded-Port %[dst_port]
    http-request add-header X-Forwarded-Proto https if { ssl_fc }
/etc/haproxy/haproxy.cfg

Change your Horizon configuration:

OPENSTACK_KEYSTONE_URL = "https://%s/v3" % OPENSTACK_HOST
Relevant parts of /etc/openstack-dashboard/local_settings.py

Open the Keycloak admin console, choose your Realm and click Clients. Open the Keystone client and add https://keystone.example.com/Shibboleth.sso/SAML2/POST to the list of valid redirect URIs.

Open shibboleth2.xml and set handlerSSL to false.

<Sessions lifetime="28800" timeout="3600" relayState="ss:mem"
                  checkAddress="false" handlerSSL="false" cookieProps="https">

Change your apache2 configuration as follows:

<VirtualHost keystone.example.com:5000>
	ServerName https://keystone.example.com/
    
    # The rest of your config here....
</VirtualHost>

You may need to change your OpenStack endpoint urls:

openstack endpoint list
openstack endpoint set --url https://keystone.example.com <id>

Appendix B: Mellon

Here we'll show how to use Mellon instead of Shibboleth.

First, install Mellon.

apt-get install libapache2-mod-auth-mellon
mkdir -p /etc/apache2/mellon

Create Mellon metadata, the first argument is the entityID of the Service Provider.

mellon_create_metadata https://keystone.example.com https://keystone.example.com/v3/OS-FEDERATION/identity_providers/keycloak_saml/protocols/saml2/auth/mellon

Make sure the NameIDFormat is set correctly in your Service Provider metadata.

<EntityDescriptor entityID="https://keystone.example.com" xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
  <SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
    <KeyDescriptor use="signing">
      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:X509Data>
          <ds:X509Certificate>...</ds:X509Data>
      </ds:KeyInfo>
    </KeyDescriptor>
    <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</NameIDFormat>
    <SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://keystone.example.com/v3/OS-FEDERATION/identity_providers/keycloak_saml/protocols/saml2/auth/mellon/logout"/>
    <AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://keystone.example.com/v3/OS-FEDERATION/identity_providers/keycloak_saml/protocols/saml2/auth/mellon/postResponse" index="0"/>
  </SPSSODescriptor>
</EntityDescriptor>

Download the IdP metadata from Keycloak.

wget -O /etc/apache2/mellon/idp-metadata.xml https://keycloak.example.com/realms/OpenStack/protocol/saml/descriptor

Alter your apache2 configuration.

<VirtualHost keystone.example.com:5000>
	
    #
    # WSGI Settings here
    #
    
    <Location /v3>
        MellonEnable "info"
        MellonSPPrivateKeyFile /etc/apache2/mellon/keystone.key
        MellonSPCertFile /etc/apache2/mellon/keystone.cert
        MellonSPMetadataFile /etc/apache2/mellon/keystone.xml
        MellonIdPMetadataFile /etc/apache2/mellon/idp-metadata.xml
        MellonEndpointPath /v3/OS-FEDERATION/identity_providers/keycloak_saml/protocols/saml2/auth/mellon
   
        # Identity provider suffix (becomes MELLON_IDP)
        MellonIdP "IDP"
        
        # Store NAME_ID as REMOTE_USER variable (used in user mapping)
        MellonSetEnvNoPrefix REMOTE_USER NAME_ID
    </Location>

    # Support request of unscoped OS-FEDERATION tokens.
    <Location /v3/OS-FEDERATION/identity_providers/keycloak_saml/protocols/saml2/auth>
        AuthType Mellon
        MellonEnable auth
    </Location>
    
    # Support WebSSO authentication (v1.2)
    <Location /v3/auth/OS-FEDERATION/websso/saml2>
        AuthType Mellon
        MellonEnable auth
    </Location>
    
    # Support WebSSO authentication (v1.3)
    <Location /v3/auth/OS-FEDERATION/identity_providers/keycloak_test/protocols/saml2/websso>
        AuthType Mellon
        MellonEnable auth
    </Location>
</VirtualHost>

Change your keystone.conf.

[saml2]
remote_id_attribute = MELLON_IDP

Open the Keycloak admin console and set the redirect URL to "https://keystone.example.com/v3/OS-FEDERATION/identity_providers/keycloak_saml/protocols/saml2/auth/mellon/postResponse".