Use Keycloak to authenticate and authorize users in Kubernetes with OIDC code flow (without password)

Guillem Riera
9 min readApr 22, 2024

--

Introduction

This is the second part of a “Keycloak for Kubernetes user authentication” series. If you are trying to setup Keycloak and Kubernetes for the first time, or if you are interested in reviewing the concepts, please check the previous part:

Use Keycloak to Authenticate and Authorize Users

In this post I will show how to use the authorization code flow (also known as the standard flow) to authenticate users.

Benefits of this approach:

  • We will use the browser to authenticate (and avoid re-entering credentials over and over).
  • We won’t setup the user credentials in the terminal nor send the user credentials in the requests.
  • No passwords involved.

Notes

  • I won’t explain how the code workflow works with much detail, you can find many official resources explaining it, including the official specification: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowSteps.
  • I won’t explain how you would setup extra Identity Providers in Keycloak, as there are plenty of possibilities. However, if you are seeking for an easy configuration, I recommend you start setting up GitHub or Google as Idp, as it is straightforward and gratifying.
  • I will showcase the steps using mostly the shell (because it is very easy to interact with and debug).
  • This is not enterprise or production code.

Requirements

  • Either follow the last part in the series (I’m using minikube) or have a working Keycloak and Kubernetes setup
  • MacOS or Linux and bash
  • curl to make the requests
  • jq to parse the response
  • kubectl to setup the CLI
  • node (I’m using node to encode the URL’s parameters and as callback server)
  • Optionally: go (I have an alternative callback implementation written in Go)

How it works, the “short” explanation

It will be easier for you to adapt this instructions to your case if you understand this bare minimum. Feel free to skip this section altogether if you know how the code flow works.

  • As the client, we will prepare a request, the so-called “Authentication Request”. We will require a code response type using the browser and a callback url where we will get the response back containing the code. We need to send the right scopes in this request too. More of this later.
  • We then send this request to Keycloak (Authorization Server), using a plain shell script for this task, which will open the default browser and start with the flow. (After opening the browser we will keep waiting for the code, more on this later).
  • You will be presented with the login screen, which depending on your identity providers might look different.
  • Keycloak then authenticates us (or redirects to the Identity Provider and the authenticate there).
  • Consent screen might be displayed: If you follow the last part, there is no End-User Consent/Authorization screen, but you might need a consent screen from your Identity Provider. This might or might not appear, depending on your setup.
  • Keycloak then sends the Authorization Code to the callback URL, which for this post is a small service running in our computer. This service will extract the code and save it temporarily.
  • As the client, we request the tokens (remember, we were waiting for this code with the script, which at this point is received). The script then continues requesting the tokens, sending the code at Keycloak’s Token Endpoint.
  • We receive and dump a response that contains an ID Token, Access Token and Refresh Token in the response body.
  • We will then validate the token’s information and configure kubectl to use the ID Token to authenticate us.

Putting everything together

Here I have written a sample backend app which dumps the token (using NodeJs and Express):

const express = require('express')
const fs = require('fs')

const app = express()
const host = process.env.SERVE || '0.0.0.0'
const port = process.env.PORT || 9999
const dumpCodeLocation = process.env.DUMP_PATH || '/tmp'

const anyGetHandler = (req, res) => {
const code = req.query.code
const state = req.query.state
if (code) {
console.log(req)
console.log(`[Info] Got code: ${code}, dumping to: ${dumpCodeLocation}`)
fs.writeFile(`${dumpCodeLocation}/code.${state}`, code, (err) => console.log(err))
}
const responseBody = `<h1>Now Get the code<h1><h2>State: ${state}</h2><h2>${code}</h2><br><h2>Go back to the terminal and inspect the console output.</h2><h2>Check ${dumpCodeLocation}/code.${state} for the dumped code.</h2>`
console.log(req.url)
console.log(req.method)
console.log(req.statusCode)
console.log(req.headers)
res.status(200).send(responseBody)
res.end()
}

app.get('*', anyGetHandler)
app.listen(port, host, console.log(`[Info] Callback listener at ${host}:${port}`))

create a package.json , install espress and start everything by calling node callback.js .

and here the same code using Go:

package main

import (
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
)

func anyGetHandler(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code != "" {
fmt.Printf("[Info] Got code: %s, dumping to: /tmp/code.%s\n", code, state)
err := ioutil.WriteFile(fmt.Sprintf("/tmp/code.%s", state), []byte(code), 0644)
if err != nil {
log.Println(err)
}
}

responseBody := `<h1>Now Get the code<h1><h2>State: ` + state + `</h2><h2>` + code + `</h2><br><h2>Go back to the terminal and inspect the console output.</h2><h2>Check /tmp/code.` + state + ` for the dumped code.</h2>`
fmt.Fprint(w, responseBody)
}

func main() {
host := os.Getenv("SERVE")
if host == "" {
host = "0.0.0.0"
}

port := os.Getenv("PORT")
if port == "" {
port = "9999"
}

http.HandleFunc("/", anyGetHandler)
fmt.Printf("[Info] Callback listener at %s:%s\n", host, port)
log.Fatal(http.ListenAndServe(host+":"+port, nil))
}

Run it with go run callback.go

Let’s prepare some configuration for the shell:

# Server and Realm
export OIDC_SERVER='YOUR_KEYCLOAK_URL'
export REALM='YOUR_REALM'
export OIDC_ISSUER_URL="${OIDC_SERVER}/realms/${REALM}"

# Client
export OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-"k8s"}

# User
export K8S_USER='YOUR_USER'

# K8S Cluster
export K8S_CLUSTER=${K8S_CLUSTER:-"minikube"}

Now let’s implement a script that starts the OIDC flow:

### OIDC code flow.

DUMP_PATH=${DUMP_PATH:-"/tmp"}

function set_browser_headers() {
export USER_AGENT=${USER_AGENT:-"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:84.0) Gecko/20100101 Firefox/84.0"}
export ACCEPT=${ACCEPT:-"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"}
export ACCEPT_LANGUAGE=${ACCEPT_LANGUAGE:-"en-GB,en;q=0.5"}
export ACCEPT_ENCODING=${ACCEPT_ENCODING:-"gzip, deflate"}
export ORIGIN=${ORIGIN:-"null"}
export UPGRADE_INSECURE_REQUESTS=${UPGRADE_INSECURE_REQUESTS:-"1"}
export CONNECTION=${CONNECTION:-"keep-alive"}
export HEADERS=${HEADERS:-"-H 'Upgrade-Insecure-Requests: ${UPGRADE_INSECURE_REQUESTS}'"}
HEADERS+=" -H 'Accept: ${ACCEPT}'"
HEADERS+=" -H 'Accept-Language: ${ACCEPT_LANGUAGE}'"
HEADERS+=" -H 'Accept-Encoding: ${ACCEPT_ENCODING}'"
HEADERS+=" -H 'Origin: ${ORIGIN}'"
HEADERS+=" -H 'Connection: ${CONNECTION}'"
}

### Optionally Set proxy settings to analyze your workflow step by step.
# Note: on macOS it is mandatory to use an alias for localhost, as localhost won't be able to be captured at all.
function set_proxy_settings() {
export PROXY=${PROXY:=''}
if [[ -n "${PROXY}" ]]; then
export PROXY_STRING="--proxy ${PROXY}"
else
export PROXY_STRING=''
fi
}

### Where is curl?
function set_curl() {
export CURL=${CURL:-$(which curl 2>/dev/null)}
}

function set_client_application() {
export OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-"k8s"}
}

function set_request_scopes() {
export SCOPES=${SCOPES:-"openid profile email name groups"}
export URL_ENCODED_SCOPES=$(node -e 'console.log(encodeURIComponent(process.argv[1]))' "${SCOPES}")
}

function set_keycloak_url() {
export OIDC_SERVER=${OIDC_SERVER:-"https://id.rieragalm.es:8443"}
}

function set_callback_url_to_receive_code() {
export CALLBACK=${CALLBACK:-"http://localhost:9999"}
}

function encode_redirect_url() {
export REDIRECT_URL="$(node -e "console.log(encodeURIComponent(process.argv[1]))" "${CALLBACK}")"
}

function set_keycloak_realm() {
export REALM=${REALM:-"es.rieragalm"}
}

function get_random_status() {
export STATUS="$(uuidgen)"
}

function set_token_endpoint() {
export TOKEN_ENDPOINT=$(${CURL} -s "${OIDC_SERVER}/realms/${REALM}/.well-known/openid-configuration" 2>/dev/null | jq -r '.token_endpoint')
# If the TOKEN_ENDPOINT is empty or "null" (we have to check for both conditions) then the issuer url is not correct.

if [[ -z ${TOKEN_ENDPOINT} ]] || [[ ${TOKEN_ENDPOINT} == "null" ]]; then
echo "[Error] TOKEN_ENDPOINT was not found. Check your keycloak url: ${OIDC_SERVER}"
exit 1
fi
}

function set_user_info_endpoint() {
export USER_INFO_ENDPOINT=$(${CURL} -s "${OIDC_SERVER}/realms/${REALM}/.well-known/openid-configuration" 2>/dev/null | jq -r '.userinfo_endpoint')
if [[ -z ${USER_INFO_ENDPOINT} ]] || [[ ${USER_INFO_ENDPOINT} == "null" ]]; then
echo "[Error] USER_INFO_ENDPOINT was not found. Check your OIDC url: ${OIDC_SERVER}"
exit 1
fi
}

function set_authorization_endpoint() {
export AUTHORIZATION_ENDPOINT=$(${CURL} -s "${OIDC_SERVER}/realms/${REALM}/.well-known/openid-configuration" 2>/dev/null | jq -r '.authorization_endpoint')
if [[ -z ${AUTHORIZATION_ENDPOINT} ]] || [[ ${AUTHORIZATION_ENDPOINT} == "null" ]]; then
echo "[Error] AUTHORIZATION_ENDPOINT was not found. Check your OIDC url: ${OIDC_SERVER}"
exit 1
fi
}

function set_browser() {
export BROWSER=${BROWSER:-"Safari"}
export HIDEBROWSER=${HIDEBROWSER:-""}
}

function set_code_dump_file() {
export CODE_DUMP=${CODE_DUMP:-"${DUMP_PATH}/code.${STATUS}"}
}

function clean_code_dump_file() {
[[ -f ${CODE_DUMP} ]] && rm ${CODE_DUMP}
}

function request_authorization_code_with_browser() {
echo "[Info] Opening your browser, you will need to authenticate if you don't have a session yet."
REQUEST_URL="${AUTHORIZATION_ENDPOINT}?client_id=${OIDC_CLIENT_ID}&response_type=code&state=${STATUS}&redirect_uri=${REDIRECT_URL}&scope=${URL_ENCODED_SCOPES}"
PLATFORM=$(uname)
if [[ "$PLATFORM" == "Darwin" ]]; then
open $HIDEBROWSER "${REQUEST_URL}" -a "${BROWSER}" &
else
echo "[Info] Open the following URL in your browser: ${REQUEST_URL}"
if [[ -e "{BROWSER}" ]]; then
${BROWSER} "${REQUEST_URL}" &
else
echo "[Warning] Browser not found. Please open the URL manually."
fi
fi
}

function wait_for_authorization_code() {
# Wait for the CODE
while [[ ! -f $CODE_DUMP ]]; do echo "[Info] Waiting for code"; sleep 1; done
CODE="$(cat $CODE_DUMP)"
if [[ -z "$CODE" ]]; then
echo "[Warning] Code dump was empty"
exit 1
else
echo "[Info] Found Code: ${CODE}"
fi
}

function request_tokens() {
export RESPONSE="$(eval "${CURL} -v -L ${HEADERS} --compressed -H 'Content-Type: application/x-www-form-urlencoded' -d 'grant_type=authorization_code' -d 'code=${CODE}' -d 'client_id=${OIDC_CLIENT_ID}' -d 'redirect_uri=${REDIRECT_URL}' -d 'scope=${SCOPES}' ${TOKEN_ENDPOINT}")"
}

function get_access_token_from_response() {
export ACCESS_TOKEN="$(echo ${RESPONSE} | jq -r '.access_token')"
echo $ACCESS_TOKEN > ${DUMP_PATH}/access_token.${STATUS}
[[ -e ${DUMP_PATH}/access_token.latest ]] && rm ${DUMP_PATH}/access_token.latest
ln -s ${DUMP_PATH}/access_token.${STATUS} ${DUMP_PATH}/access_token.latest
}

function get_id_token_from_response() {
export ID_TOKEN="$(echo ${RESPONSE} | jq -r '.id_token')"
echo $ID_TOKEN > ${DUMP_PATH}/id_token.${STATUS}
[[ -e ${DUMP_PATH}/id_token.latest ]] && rm ${DUMP_PATH}/id_token.latest
ln -s ${DUMP_PATH}/id_token.${STATUS} ${DUMP_PATH}/id_token.latest
}

function get_refresh_token_from_response() {
export REFRESH_TOKEN="$(echo ${RESPONSE} | jq -r '.refresh_token')"
echo $REFRESH_TOKEN > ${DUMP_PATH}/refresh_token.${STATUS}
[[ -e ${DUMP_PATH}/refresh_token.latest ]] && rm ${DUMP_PATH}/refresh_token.latest
ln -s ${DUMP_PATH}/refresh_token.${STATUS} ${DUMP_PATH}/refresh_token.latest
}

function read_authorization_token() {
export HEADER="$(echo ${ACCESS_TOKEN} | jq -R 'gsub("-";"+") | gsub("_";"/") | split(".") | .[0] | @base64d | fromjson')"
export PAYLOAD="$(echo ${ACCESS_TOKEN} | jq -R 'gsub("-";"+") | gsub("_";"/") | split(".") | .[1] | @base64d | fromjson')"
export SIGNATURE="$(echo ${ACCESS_TOKEN} | jq -R 'gsub("-";"+") | gsub("_";"/") | split(".") | .[2] | @base64d')"
}

function read_id_token() {
export ID_HEADER="$(echo ${ID_TOKEN} | jq -R 'gsub("-";"+") | gsub("_";"/") | split(".") | .[0] | @base64d | fromjson')"
export ID_PAYLOAD="$(echo ${ID_TOKEN} | jq -R 'gsub("-";"+") | gsub("_";"/") | split(".") | .[1] | @base64d | fromjson')"
export ID_SIGNATURE="$(echo ${ID_TOKEN} | jq -R 'gsub("-";"+") | gsub("_";"/") | split(".") | .[2] | @base64d')"
}

function dump_id_token() {
echo "[ID TOKEN HEADER]"
echo $ID_HEADER | jq '.'
echo "[ID TOKEN PAYLOAD]"
echo $ID_PAYLOAD | jq '.'
}

function get_user_info() {
echo "[Info] User Info (Access Token)"
USER_INFO_RESPONSE=$(eval "${CURL} -s -X POST -H 'Authorization: Bearer ${ACCESS_TOKEN}' -H 'Content-Type: application/x-www-form-urlencoded' '${USER_INFO_ENDPOINT}'")
echo $USER_INFO_RESPONSE | jq '.'
}

function main() {
# Setup
set_curl
set_browser_headers
set_browser
set_client_application
set_proxy_settings
set_request_scopes
set_callback_url_to_receive_code
encode_redirect_url
set_keycloak_url
set_keycloak_realm
get_random_status
set_authorization_endpoint
set_token_endpoint
set_user_info_endpoint

# Pre-Flight: Cleanup
set_code_dump_file
clean_code_dump_file

# Action: Start the Authorization Flow
request_authorization_code_with_browser
wait_for_authorization_code

# Exchange Code for tokens
request_tokens
get_access_token_from_response
get_id_token_from_response
get_refresh_token_from_response

# Read JWT Contents
read_authorization_token
read_id_token
read_refresh_token

# Check if the user info and ensure the token is valid.
get_user_info
}

main

Start the script and follow along in the browser.

At some point (given that you authenticate properly), you will see a token being dumped from the callback URL (your local server), and that the script continues as shows the tokens on screen.

> go run callback.go
[Info] Callback listener at 0.0.0.0:9999
[Info] Got code: 223d652b-06c5-4fe6-98cb-0250ea4482eb.1a66cdd7-2134-4c18-a993-e8ed267a417c.7294a679-fa8c-425f-bf16-b79b8a2fd985, dumping to: /tmp/code.B034C1C6-5EDF-4039-842B-9FEEEB43DAA8

Note: The script will display all token details on screen, which is too verbose to display here.

Setting Kubectl

Now that we have a new Token ID we can use it to setup Kubectl.

I have written this small wrapper script too:

#!/usr/bin/env bash

# Usage: ./SetKubectl.sh
# Note: This script is intended to be used after StartOIDCCodeFlow.sh

# User settings:

export K8S_USER=${K8S_USER:-"YOUR_USER"}

# Cluster settings
export K8S_CLUSTER=${K8S_CLUSTER:-"minikube"}

# OIDC settings
export OIDC_ISSUER_URL=${OIDC_ISSUER_URL:-"$OIDC_SERVER/auth/realms/$REALM"}
export OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-"k8s"}

function set_tokens() {
export ID_TOKEN=$(cat /tmp/id_token.latest)
export REFRESH_TOKEN=$(cat /tmp/refresh_token.latest)

# If the ID_TOKEN is empty or "null" we have to stop the execution.
if [[ -z ${ID_TOKEN} ]] || [[ ${ID_TOKEN} == "null" ]]; then
echo "[Error] ID_TOKEN was not found. Check your OIDC url: ${OIDC_ISSUER_URL}"
exit 1
fi
}

function set_kubectl_credentials() {
kubectl config set-credentials ${K8S_USER} \
--auth-provider=oidc \
--auth-provider-arg=idp-issuer-url=${OIDC_ISSUER_URL} \
--auth-provider-arg=client-id=${OIDC_CLIENT_ID} --auth-provider-arg=refresh-token=${REFRESH_TOKEN} --auth-provider-arg=id-token=${ID_TOKEN}
}

function set_kubectl_context() {
kubectl config set-context ${K8S_USER} --user=${K8S_USER} --cluster=${K8S_CLUSTER}
}

function set_kubectl_current_context() {
kubectl config use-context ${K8S_USER}
}

function check_whoami() {
kubectl auth whoami
kubectl auth can-i get pods --all-namespaces
}

function main() {
set_tokens
set_kubectl_credentials
set_kubectl_context
set_kubectl_current_context
check_whoami
}

main

If everything went right, you will now be authenticated with your user!

./SetKubectl.sh
User "guillem.riera" set.
Context "guillem.riera" modified.
Switched to context "guillem.riera".
ATTRIBUTE VALUE
Username guillem.riera
Groups [k8s system:authenticated]
yes

Wrapping up

We have seen how to authenticate users by starting a browser and logging in with Keycloak (without using passwords!) and creating a kubectl config out of the acquired tokens.

I hope this post helps you both understand and implement OIDC login in Kubernetes using Keycloak.

What’s next?

Now that we know how the workflow works, we would ideally create a small tool that does all of it, instead of relying on shell scripting. Ideally, this tool could be implemented as a kubectl plugin, so that it makes everything easier for the end users.

Thanks for reading!

--

--

Guillem Riera

Principal Technical Consultant, DevOps, CICD Architect