Single Sign On
Problem Statement
Our current log in system, which is based on LDAP, has security issues. It doesn’t allow for 2FA, and we enter our password in a lot of different masks that could be compromised.
We use Keycloak as the SSO solution for internal services and want to use it for customer clusters as well.
Keycloak uses Clients
to represent applications that can be logged into.
We need to create a Client
for each customer cluster and configure it to map the correct service group in LDAP.
Creating such clients in Keycloak is a manual process and very error prone, as it involves a lot of manual steps and a rather interesting UI. We need to automate this process.
All known clusters are tracked in Lieutenant, our configuration database.
We can use this information to create the Clients
in Keycloak.
Secrets are stored in Vault.
We can use the Vault API to store the client secrets.
High Level Goals
-
Keycloak configuration is automatically created for all current and future OpenShift 4 clusters
-
Keycloak configuration is automatically created for all vClusters running on OpenShift 4
-
OIDC client secrets are automatically added to the cluster configuration
-
LDAP Group can be mapped to cluster roles
-
As many configuration parameters as possible are inferred from the cluster configuration, but can be overridden.
Implementation
A controller is deployed on the Lieutenant vCluster, reconciling the Cluster
objects.
It creates the Clients
in Keycloak and adds the client secret to Vault.
Missing information is inferred from dynamic facts or annotations on the Cluster
object.
The Cluster
object, and its facts, are the source of truth
The Cluster
object is the source of truth for the client configuration.
The controller reconciles the Cluster
object and creates, or, if differing, updates the Client
in Keycloak.
The Keycloak client objects can be templated
The controller calls a Jsonnet template and makes the cluster and tenant objects available.
The result of the file is used to create or update the Client
in Keycloak.
This allows us to expand the template without having to change the controller.
local cluster = std.native('cluster')(); local tenant = std.native('tenant')(); local redirectUris = if cluster.spec.facts.distribution == 'openshift4' then [ cluster.status.facts.oauthDomain + '/oauth2/callback', ] else [ 'http://localhost:18000', 'http://localhost:8000', ]; { clientID: 'cluster_' + cluster.metadata.name, optionalClientScopes+: [ 'custom' ], redirectUris: redirectUris, }
The controller writes the client secrets to Vault
The controller writes the client secret to Vault.
Both the cluster id and the tenant id are used as path segments.
t-ancient-morning-1764/c-413-clouscale/vshn-keycloak-secret
for example.
Steward is extended to write the OAuth domain to the Lieutenant dynamic facts
Steward provides the oauth domain in the dynamic facts by reading the cluster route.
❯ kubectl -n openshift-authentication get route oauth-openshift -o=jsonpath='{.spec.host}'
oauth-openshift.apps.cluster-domain.dev
Steward is extended to write a defined ConfigMap to the Lieutenant dynamic facts
To allow for a way to write back static "dynamic" facts, we add a ConfigMap to the Lieutenant namespace that gets added to the dynamic facts.
This config map is managed by component-steward
and new facts can be added through the hierarchy.
parameters:
steward:
additionalFacts:
vshnLdapServiceId: "${vshnLdap:serviceId}"
The controller maps LDAP groups to local client roles
The controller creates client roles in Keycloak. It registers the client local roles with the matching groups in LDAP.
The group mapping can also be manipulated in a template:
local cluster = std.native('cluster')(); local tenant = std.native('tenant')(); local serviceGroup = '/LDAP_Customers/Service ' + if std.objectHas(cluster.status.facts, 'vshnLdapId') then cluster.status.facts.vshnLdapId else cluster.metadata.name; [ { group: '/LDAP/VSHN openshiftroot', (1) role: 'vshn-openshiftroot', (2) }, { group: '/LDAP/VSHN openshiftrootswissonly', role: 'vshn-openshiftrootswissonly' }, { group: serviceGroup, role: 'customer' }, ]
1 | Keycloak group from LDAP |
2 | Keycloak client role |
Example Cluster Manifest
apiVersion: syn.tools/v1alpha1
kind: Cluster
metadata:
finalizers:
- cluster.lieutenant.syn.tools
- sso.syn.tools/keycloak-client (1)
name: c-holy-fire-9875
namespace: lieutenant
annotations:
oidc.sso.syn.tools/redirect-uris: '["localhost:18000","localhost:8000"]' (2)
sso.vshn.net/ldap-id: ClusterHolyFire9875 (3)
spec:
displayName: Cybertron Prod 1
facts:
distribution: openshift4 (4)
[...]
tenantRef:
name: t-frosty-forest-1224 (5)
status:
facts:
kubernetesVersion: '{"buildDate":"2023-09-11T02:22:18Z","compiler":"gc","gitCommit":"f10a517f7199bdae922a70893d85eb96a76f5c2d","gitTreeState":"clean","gitVersion":"v1.26.7+c7ee51f","goVersion":"go1.19.10
X:strictfipsruntime","major":"1","minor":"26","platform":"linux/amd64"}'
openshiftVersion: '{"Major":"4","Minor":"13","Patch":"13"}'
oauthDomain: https://oauth-openshift.apps.c-holy-fire-9875.dev (6)
vshnLdapServiceId: ClusterHolyFire9875 (7)
1 | The sso.syn.tools/keycloak-client finalizer is added to the cluster object to allow cleanup of the Keycloak client when the cluster is deleted. |
2 | The oidc.sso.syn.tools/redirect-uris annotation is used to override the default redirect uris. |
3 | The sso.vshn.net/ldap-id annotation is used to override the default LDAP group mapping. |
4 | The distribution fact is used to determine if Openshift specific redirect URIs should be used. |
5 | The tenantRef is used to determine the tenant the cluster belongs to.
The tenant should be included in the templates. |
6 | The oauthDomain fact is used to determine the redirect URI on Openshift 4 clusters. |
7 | The vshnLdapId fact is used to determine the LDAP group mapping.
It’s read from a config map in the Steward namespace. |
Example Keycloak Client Manifest
{
"clientId": "cluster_c-holy-fire-9875", (1)
"name": "Cybertron Prod 1 (c-holy-fire-9875)",
"description": "",
"rootUrl": "https://oauth-openshift.apps.c-holy-fire-9875.dev", (2)
"adminUrl": "",
"baseUrl": "",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "SED4zzNnlYsdWQhA4yugynze1yZLYelr4hMZfv4K", (3)
"redirectUris": [
"/oauth2/callback" (4)
],
"webOrigins": [],
...,
"protocol": "openid-connect",
"attributes": { ... },
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [ ... ],
"optionalClientScopes": [ ... ],
}
1 | The client ID is derived from the cluster name. |
2 | The root URL is derived from the oauthDomain fact on Openshift 4 clusters. |
3 | The client secret is stored in Vault. |
4 | The redirect URI is derived from the oauthDomain fact on Openshift 4 clusters or overridden by the oidc.sso.syn.tools/redirect-uris annotation. |