Keycloak OIDC - Declarative Configuration on Kubernetes with Crossplane
Keycloak is many things, but simple and friendly aren’t among them. Another major issue is the reliance on the UI in most configuration guides—Not good, not good.
Let’s try to bring some clarity by putting things down in a declarative configuration. This is not a Keycloak guide, we’ll only touch on some simple concepts.
Grafana is going to be our Guinea pig.
You can find all the code in this repository: driv/keycloak-configuration
The TOOLS
The Operator
You might be happy to hear that Keycloak has an official Kubernetes operator. Which, according to Operator Hub, has capability level IV
. If you’ve tried using it you know that’s not true. Level I
is defined as “Automated application provisioning and configuration management” which this operator does not fully cover.
But, it can provide us with an instance to build on.
Most things are self-explanatory.
bootstrapAdmin
uses a secret to initialize both a user and a service. We’ll use the user
and Crossplane will use the service
.
We must enable backchannelDynamic
to allow Grafana to talk to Keycloak through the internal Kubernetes Service.
Terraform and Crossplane
Wait, what!?
Since the Operator falls short, we need an alternative. Luckily Terraform has what we need and more.
Don’t worry, we are not going to be using Terraform directly! We don’t want to lose the reconciliation capabilities of Kubernetes. We’ll be using it through this great Crossplane Provider.
Keycloak Concepts and Configuration
Independently of how we are configuring our authentication, there are a few things we need on the Keycloak side.
Realm
This is how Keycloak groups resources. Each realm has its clients (applications), users, groups, etc. It’s recommended not to use the master realm, but to reserve it to create other realms.
Client
In this case Grafana.
With OIDC the client performs 2 tasks:
- Receives a jwt token from Keycloak and validates it.
- Retrieves the userinfo from Keycloak using the received token.
The second step is not always necessary, we could include all the information needed in the token.
Scopes
To determine what user information the client has access to we define scopes. e.g. name
, email
, etc.
One scope we are particularly interested in is roles
which unfortunately is not part of the OIDC definition and not part of the default data returned by Keycloak in the token or the userinfo endpoint.
To fix this, we need to define a mapper.
Mapper
This is an important and not intuitive part of the configuration. We need to tell Keycloak to include the user roles in the userinfo data so Grafana can access it. We could do this as a global configuration, mapping any role into the token or userinfo, or per client.
Crossplane
Provider Installation and Configuration
Of course, the Crossplane provider is installed fully declaratively. You can check the manifest.
The only thing to take into consideration is that the client_id
and secret
have to match what we defined in the instance configuration secret, I’ve not been able to find a simple way to reuse those values.
Keycloak Configuration
The whole configuration can be found in this manifest.
Keycloak Configuration Resources
We’ll go over the interesting parts.
Client Definition
You can reference resources so you don’t have to know their IDs to target them in the configuration.
realmIdRef: name: "sdlcbox" providerConfigRef: name: sdlcbox writeConnectionSecretToRef: name: grafana-oauth-client namespace: monitoring
If you are familiar with Terraform, you know that resources have outputs and we can use their values in other resources. With writeConnectionSecretToRef
we can store the autogenerated client secret in a Kubernetes Secret
and make it available to Grafana.
Mapper Definition
As we’ve mentioned before, we have to configure Keycloak to include the roles somewhere. We could have used a mapper at the realm level to include all the roles our user has assigned, but the Provider does not currently support it, so we’ll do it for the Client we just defined.
apiVersion: client.keycloak.crossplane.io/v1alpha1 kind: ProtocolMapper metadata: name: client-role spec: forProvider: clientIdRef: name: grafana name: "client roles" realmIdRef: name: "sdlcbox" protocol: "openid-connect" protocolMapper: "oidc-usermodel-client-role-mapper" config: { "introspection.token.claim" : "false", "multivalued" : "true", "userinfo.token.claim" : "true", "id.token.claim" : "false", "lightweight.claim" : "false", "access.token.claim" : "false", "claim.name" : "resource_access.${client_id}.roles", "jsonType.label" : "String" } providerConfigRef: name: sdlcbox
We need to specify the type of mapper and the protocol it applies to:
protocol: "openid-connect"
protocolMapper: "oidc-usermodel-client-role-mapper"
And we also need to define where to include it (userinfo) and how:
"userinfo.token.claim" : "true"
"claim.name" : "resource_access.${client_id}.roles"
This will result in the roles being included in the /userinfo
response this way:
"resource_access": {
"grafana": {
"roles": ["admin", "editor"]
}
}
Users, group and role-mapping
I’ve not generated manifests for these resources, but it should be trivial to manually create them. To be able to test the configuration we are going to need one user with one of the roles created for the Grafana client (admin, editor, viewer).
Grafana
Grafana configuration is almost default apart from auth:
auth_url
is where the user is going to get redirected to for sign-intoken_url
is the Keycloak API endpoint used to renew the tokenapi_url
is where Grafana will retrieve the roles fromrole_attribute_path
uses JMESPath to extract the role information and map it to a Grafana role.
Grafana being a 12 factor app lets us overwrite some configuration with an environment variable so we can use the secret generated by the Client
definition.
Conclusion
Despite having to use multiple different tools we were able to achieve a declarative configuration for a Client on Keycloak. I’m surprised that Keycloak does not provide a simpler mechanism, since auth(z|n)
seems to me an area that would benefit from a reviewable versionable and continuously reconciled configuration.
Considerations
This code is just a PoC, definitely not suited for production. Secrets should be handled with an external tool.