Using OIDC Authentication with Kubernetes

Intro

This is a quick post to go over how you can configure Kubernetes to authenticate clients with OpenID Connect authentication. This has some advantages over alternatives like using X509 Client Certificate verification like:

  • Revoking old or leaked credentials is easier; just update the client’s client secret
  • Clients can be centrally managed at your Identity Provider including by providing groups for use in RBAC
  • You don’t need to modify the CA authority that’s probably already been set up by your Kubernetes distribution

In this post, we’ll do this without using ’non-standard’ tools. We’ll do this by:

  • Configuring the Kubernetes API Server to communicate with the Identity Provider
  • Setting up a client with the Identity Provider and storing the client secret locally
  • Writing a short script to obtain an ID Token from the Identity Provider
  • Configuring kubectl to call the script to obtain the ID Token

All we’ll need for this is Kubernetes (tested on RKE2 v1.35.1), kubectl (tested with 1.34.1), curl, jq, and an OpenID Connect compliant Identity Provider. This post will be tailored towards Keycloak (v26.5.4) but this should work for any Identity Provider.

Note that if you’re willing to use kubelogin as a kubectl plugin, you would not need to store the client secret locally; instead you would authenticate directly with your Identity Provider in a standard browser flow. You wouldn’t need to write a script to obtain an ID Token either.

Kubernetes API Server

The relevant documentation for this is: Authentication. I will assume that the StructuredAuthenticationConfiguration Kubernetes feature gate is enabled. This has been the default since Kubernetes v1.30.

Write an AuthenticationConfiguration like the following

apiVersion: apiserver.config.k8s.io/v1
kind: AuthenticationConfiguration
jwt:
- issuer:
    url: https://idp.example.com
    audiences:
    - k8s
    audienceMatchPolicy: MatchAny
  claimMappings:
    username:
      claim: sub
      prefix: "oidc:"
    groups:
      claim: groups
      prefix: "oidc:"
    uid:
      claim: sub
  userValidationRules:
  - expression: "!user.username.startsWith('system:')"
    message: 'username cannot used reserved system: prefix'
  - expression: "user.groups.all(group, !group.startsWith('system:'))"
    message: 'groups cannot used reserved system: prefix'

Here make sure that:

  • The OpenID Connect .well-known/openid-configuration endpoint is immediately above the issuer URL (i.e. at https://idp.example.com/.well-known/openid-configuration). In Keycloak’s case, I have a realm called ‘machine’ so my well-known would be at: https://idp.example.com/realms/machine/.well-known/openid-configuration. That means that my issuer URL should be: https://idp.example.com/realms/machine.
  • Your Identity Provider is able to issue client ID Tokens with an audience of k8s or change it to if different audience if needed.
  • Your Identity Provider is able to provide a groups claim with your ID Token for use as Kubernetes groups. This may vary by Identity Provider.

Write the AuthenticationConfiguration to a file somehwere, say /path/to/authconf.yaml and start the Kubernetes API Server referring with: --authentication-config=/path/to/authconf.yaml

Client

Note that here we are using a client and not a regular user account on the Identity Provider. This better represents the authentication flow of programmatic authentication (AKA M2M) instead of a human entering a username and password. kubelogin would be a better choice if you wanted to authenticate actual user accounts.

Create a client on your Identity Provider. ‘Client authentication’ or ‘confidential access’ should be turned on. Additionally ‘Service account roles’ or ‘Client Credentials Grant’ should be turned on. Finally, set up the clients scopes so that issued ID Tokens contain a groups claim, a sub claim and an aud (audience) claim with the issuer audiences value from the AuthenticationConfiguration above.

You can do this on Keycloak by assosciating the client with the pre-existing microprofile-jwt scope (providing groups through client service account roles), basic scope, and by creating a custom client scope (named, say, k8s), configuring a new mapper with the “Audience” name and including a custom audience with, in the above example, the value k8s. Assign the k8s client scope to the client you created.

Save the client id and secret for the client; we’ll use them in the next section. I will assume they are respectively $CLIENT_ID and $CLIENT_SECRET. Also note the claims your client needs to get the groups claim and the correct audience. This should also include the standard openid scope. That means that for Keycloak, we have SCOPES=openid microprofile-jwt k8s

Token Fetch Script

The script for this is quite simple. First, go to the Well Known endpoint of your Identity Provider (https://idp.example.com/.well-known/openid-configuration) and find the value for the token_endpoint key. I will assume that it is https://idp.example.com/protocol/openid-connect/token. Now we can write the script:

#!/bin/bash
curl -s -d grant_type=client_credentials -d client_id="$CLIENT_ID" -d client_secret="$CLIENT_SECRET" -d scope="$SCOPES" https://idp.example.com/protocol/openid-connect/token |\
jq '{"status": {"token": .id_token, "expirationTimestamp": (now + .expires_in) | todate}, "apiVersion": "client.authentication.k8s.io/v1", "kind": "ExecCredential"}'

I will assume this script is at /path/to/token.sh. Don’t forget to make it executable.

This script uses a generated ID token to authenticate the Kubernetes API. It it isn’t required by the specification for the Identity Provider (https://datatracker.ietf.org/doc/html/rfc6749#section-4.4) to return an ID token for a Client Credentials flow. KeyCloak does provide an ID token for the above request. If your Identity Provider provides a JWT access token with the correct claims and audience, you can use that instead.

All jq does here is format the ID Token into an ExecCredential object. Using it is nice because it prevents any malicious or accidental JSON syntax breakage but you could remove it if you wanted to. Also, here, we’re writing the client secret to a file. You might prefer to use a different approach to store or cache it (e.g. GPG).

kubectl Configuration

Finally we write a configuration file for kubectl (normally at ~/.kube/config). This will look similar to:

apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    server: https://kubernetes.default.svc.cluster.local:6443
  name: default
contexts:
- context:
    cluster: default
    user: oidc
  name: default
current-context: default
kind: Config
users:
- name: oidc
  user:
    exec:
      apiVersion: client.authentication.k8s.io/v1
      command: /path/to/token.sh
      interactiveMode: Never

Using It

Now you can try the following

kubectl auth whoami

If everything is working, you will see output like:

ATTRIBUTE                                           VALUE
Username                                            oidc:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
UID                                                 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Groups                                              [oidc:xxxxxxxxxxxxxx oidc:xxxxxxxxxxxxxxxxx oidc:xxxxxxxxxxxxxxxxxxxxx system:authenticated]
Extra: authentication.kubernetes.io/credential-id   [JTI=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx]

This shows that you’ve inherited a username from the Identity Provider (prefixed with oidc:) and groups from the Identity Provider (prefixed with oidc:). You will need to put RBAC in place to permit your user to do things. I won’t go over that here.

Built with Hugo
Theme Stack designed by Jimmy