Using OIDC Authentication with Kubernetes, Keycloak and the OAuth 2.0 Device Authorization Grant

Intro

This page is an alternative/extension of my previous post which describes how to use the Device Authorization Grant with Kubernetes & Keycloak. With this approach the usage flow is as follows:

  • The user runs a token.sh script
  • The script prints a link to the Identity Provider for the user to click
  • The user clicks the link and permits information to be passed to the scripts
  • The script sets the identity token and refresh token in kubectl’s configuration
  • Future invocations of kubectl renew the ID Token with the Refresh Token if possible

This has been tested with RKE2 v1.35.1, kubectl 1.34.1, curl, jq, an Keycloak (v26.5.4) but this should work for any standard compliant Identity Provider.

Kubernetes API Server

Setting up the API Server is the same as before

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: upn
      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 to it with: --authentication-config=/path/to/authconf.yaml

Client

Here, unlike the previous post, create a public client (i.e. one that does not have a client secret). The only authentication flow enabled should be the OAuth 2.0 Device Authorization Grant. The client ID should normally be the same as one of the audiences items in the configuration above – I will assume that it is k8s. Keycloak will insert this as the aud claim. Finally, set up the clients scopes so that issued ID Tokens contains a sub claim, upn claim, and a groups claim. On Keycloak, this can be done by assigning the predefined basic and microprofile-jwt scopes.

Token Fetch Script

A rough script to facilitate the device authorization flow is below:

#!/bin/bash
CACHE=/run/user/$(id -u)/k8s-oauth-cache
mkdir -p "$CACHE"

device_req() {
	curl -s -d client_id=k8s -d scope="openid basic microprofile-jwt" https://idp.example.com/realms/REALMNAME/protocol/openid-connect/auth/device
}

device_obt() {
	jq -r .device_code "$CACHE/device" | awk '{print "device_code="$1}' | curl -s -d grant_type="urn:ietf:params:oauth:grant-type:device_code" -d client_id=k8s --data @- https://idp.example.com/realms/REALMNAME/protocol/openid-connect/token
}

apply() {
	kubectl config set-credentials oidc --auth-provider oidc --auth-provider-arg=idp-issuer-url=https://idp.example.com/realms/REALMNAME --auth-provider-arg=client-id=k8s --auth-provider-arg=refresh-token="$(jq -r .refresh_token "$CACHE/token")" --auth-provider-arg=id-token="$(jq -r .id_token "$CACHE/token")"
	exit "$?"
}

device_req > "$CACHE/device"
INTERVAL="$(jq -r .interval "$CACHE/device")"
echo "Click on:" > /dev/stderr
jq .verification_uri_complete "$CACHE/device" > /dev/stderr
for i in $(seq 1 60)
do
	sleep "$INTERVAL"
	device_obt > "$CACHE/token"
	jq -e .id_token "$CACHE/token" > /dev/null && apply
	jq -e '.error=="slow_down"' "$CACHE/token" > /dev/null && INTERVAL="$((INTERVAL+5))"
done
exit 1

Replace REALMANME with the name of your realm. Don’t forget to make it executable.

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

Using It

Now you can try the following

token.sh req

If everything is working, you will see a message like:

Click on:
"https://idp.example.com/realms/REALMNAME/device?user_code=XXXX-XXXX"

After clicking on the link and authenticating, your tokens will then be printed. You can then run K8S commands like kubectl auth whoami which will produce output similar to the below:

ATTRIBUTE                                           VALUE
Username                                            oidc:xxxxxxs
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