Introduction
In my previous posts, I mentioned the important topic of zero static credentials. This is especially relevant today, as interactions with AI agents in protected environments can unexpectedly expose credentials.
In this part, I explain how to authenticate GitHub Actions jobs so they can provision Keycloak without static credentials. A dirty trick is involved, and I explain it below.
In the same way, I can access resource servers from GitHub Actions workflows by using Keycloak as an identity broker.
GitHub OIDC Provider
GitHub provides a GitHub Actions OIDC provider, so our repository, refs, pull requests, and even environments can act as identities for resource servers that accept OIDC tokens.
The token format and the sub claim are covered in my previous post.
For AWS authentication, I used an official action that helps retrieve a token from the GitHub OIDC provider. Keycloak does not currently have an official action, so I had to use custom steps to obtain the token.
name: Keycloak Token Action
description: 'Action to obtain a Keycloak token'
inputs:
keycloak-url:
description: 'The URL of the Keycloak server'
required: true
keycloak-realm:
description: 'The Keycloak realm'
required: true
keycloak-base-path:
description: 'The base path for Keycloak (optional)'
required: false
runs:
using: "composite"
steps:
- name: Get JWT token
id: get-gh-token
env:
KEYCLOAK_AUDIENCE: "${{ inputs.keycloak-url }}${{ inputs.keycloak-base-path || '' }}/realms/${{ inputs.keycloak-realm }}"
uses: actions/github-script@v9
with:
script: |
const audience = process.env['KEYCLOAK_AUDIENCE'];
let ghToken = await core.getIDToken(audience);
core.setOutput('ghToken', ghToken);
function decodeJWT(jwtToken) {
try {
// A JWT has 3 parts: Header, Payload, Signature
const [header, payload, signature] = jwtToken.split('.');
// Decode base64url string to standard UTF-8 text
const decodedPayload = Buffer.from(payload, 'base64').toString('utf8');
// Parse the string into a readable JavaScript object
return JSON.parse(decodedPayload);
} catch (error) {
console.error("Invalid JWT format", error);
return null;
}
}
console.log('----');
console.log(decodeJWT(ghToken));
console.log('----');
This composite action uses actions/github-script and the core.getIDToken(audience) function with a custom audience value. Keycloak expects the aud claim to match the issuer_url of the Keycloak realm endpoint.
The aud (audience) Claim. Value that identifies the Authorization Server as an intended audience. The Authorization Server MUST verify that it is an intended audience for the token. The Audience SHOULD be the URL of the Authorization Server's Token Endpoint.
Example of a decoded GitHub token from the test workflow:
{
actor: 'AleksandrSor',
actor_id: '...',
aud: 'https://<KEYCLOAK_URL>/auth/realms/demo-infra-project',
...
environment: 'test-keycloak',
...
iss: 'https://token.actions.githubusercontent.com',
...
sub: 'repo:AleksandrSor/demo-infra:environment:test-keycloak',
...
}
More information about a custom way of obtaining a GitHub OIDC token can be found here.
Now I need to exchange my GitHub token for a Keycloak token with the correct permissions to manage my Keycloak instance. Before I can do that, I need to register the GitHub OIDC provider in Keycloak.
Keycloak JWT Federation
I want to eliminate the need to store static credentials, and Keycloak JWT federation helps me achieve that.
The first step in setting up federated client authentication is to define a trust relationship between Keycloak and the external identity providers. This is done by creating a new identity provider in the realm.
Keycloak currently supports three identity provider types for federated client authentication:
- OpenID Connect
- SPIFFE
- Kubernetes
More information is available in this blog post.
Since the GitHub OIDC provider does not expose a client authentication endpoint, it cannot be registered properly as an OpenID Connect provider. It also cannot be used as a SPIFFE provider.
This is where the dirty trick comes in. I register the GitHub OIDC provider as Kubernetes. This works because Keycloak expects an OIDC discovery endpoint at <ISSUER URL>/.well-known/openid-configuration, and GitHub exposes it there: https://token.actions.githubusercontent.com/.well-known/openid-configuration.
Register Identity Provider
locals {
github_actions_issuer = "https://token.actions.githubusercontent.com"
}
resource "keycloak_kubernetes_identity_provider" "github_actions" {
alias = "github-actions"
realm = keycloak_realm.realm.id
issuer = local.github_actions_issuer
lifecycle {
prevent_destroy = true
}
}
Create a client for GitHub OIDC
This client uses the Service Account Roles authentication flow with
JWT Federated Client Authentication.
github-actions.tf
resource "keycloak_openid_client" "github_actions" {
realm_id = keycloak_realm.realm.id
client_id = "repo:${local.config.env.repository.name}:environment:${local.config.env.repository.protected_environment}"
name = "github-actions-${replace(local.config.env.repository.name, "/[^a-zA-Z0-9]/", "-")}-env-${local.config.env.repository.protected_environment}"
enabled = true
access_type = "CONFIDENTIAL"
standard_flow_enabled = false
direct_access_grants_enabled = false
service_accounts_enabled = true
client_authenticator_type = "federated-jwt"
extra_config = {
"jwt.credential.issuer" = keycloak_kubernetes_identity_provider.github_actions.alias
"jwt.credential.sub" = "repo:${local.config.env.repository.name}:environment:${local.config.env.repository.protected_environment}"
}
description = jsonencode(local.config.common_tags)
}
Next, assign roles to the service account used by the GitHub Actions workflow.
github-actions.tf
resource "keycloak_openid_client_service_account_role" "github_actions_service_account_role_realm_admin" {
realm_id = keycloak_realm.realm.id
service_account_user_id = keycloak_openid_client.github_actions.service_account_user_id
client_id = data.keycloak_openid_client.realm_management.id
role = data.keycloak_role.realm-admin.name
}
resource "keycloak_openid_client_service_account_role" "github_actions_service_account_role_query_realms" {
realm_id = keycloak_realm.realm.id
service_account_user_id = keycloak_openid_client.github_actions.service_account_user_id
client_id = data.keycloak_openid_client.realm_management.id
role = data.keycloak_role.query-realms.name
}
data "keycloak_openid_client" "realm_management" {
realm_id = keycloak_realm.realm.id
client_id = "realm-management"
}
data "keycloak_role" "realm-admin" {
realm_id = keycloak_realm.realm.id
client_id = data.keycloak_openid_client.realm_management.id
name = "realm-admin"
}
data "keycloak_role" "query-realms" {
realm_id = keycloak_realm.realm.id
client_id = data.keycloak_openid_client.realm_management.id
name = "query-realms"
}
Token Exchange
Now I can exchange my GitHub token for a Keycloak token.
inputs:
...
keycloak-client-id:
description: 'The Keycloak client ID for token retrieval'
required: true
...
runs:
using: "composite"
steps:
...
- name: Get KC token
id: get-kc-token
shell: bash
run: |
KEYCLOAK_RESPONSE=$(curl -s "${{ inputs.keycloak-url }}${{ inputs.keycloak-base-path || '' }}/realms/${{ inputs.keycloak-realm }}/protocol/openid-connect/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'scope=openid' \
-d 'client_id=${{ inputs.keycloak-client-id }}' \
-d 'client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
-d 'client_assertion=${{ steps.get-gh-token.outputs.ghToken }}' | jq -r '.')
KEYCLOAK_TOKEN=$(echo $KEYCLOAK_RESPONSE | jq -r '.access_token')
echo "keycloakAccessToken=${KEYCLOAK_TOKEN}" >> $GITHUB_OUTPUT
echo "keycloakIdToken=${KEYCLOAK_TOKEN}" >> $GITHUB_OUTPUT
echo "----"
echo "KC Response:"
echo "${KEYCLOAK_RESPONSE}"
echo "----"
echo "Decoded KC Token:"
echo "${KEYCLOAK_TOKEN}" | jq -R 'split(".") | .[0],.[1] | @base64d | fromjson'
echo "----"
The most important parts of the request are:
-
grant_type=client_credentials: specifies the authentication flow type. -
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer: specifies the assertion type used for client authentication. -
client_assertion=${{ steps.get-gh-token.outputs.ghToken }}: passes the GitHub token as the client assertion. -
client_id=${{ inputs.keycloak-client-id }}: the client ID of the Keycloak client resource created in IaC (keycloak_openid_client.github_actions).
Documentation is available here.
Example of a decoded Keycloak token from the test workflow:
{
...
"iss": "https://<KEYCLOAK_URL>/auth/realms/demo-infra-project",
"aud": [
"realm-management",
"account"
],
"sub": "2a428d3a-4359-49f4-abcd-607fdb23aa8f",
...
"azp": "repo:AleksandrSor/demo-infra:environment:test-keycloak",
"resource_access": {
"realm-management": {
"roles": [
"view-realm",
"query-realms"
]
},
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
...
"client_id": "repo:AleksandrSor/demo-infra:environment:test-keycloak"
}
Testing the Keycloak token
To run a quick test, we can execute the following job against our Keycloak instance:
name: test JWT
on:
push:
branches:
- test/test-jwt
permissions:
contents: read
id-token: write # # Required to request a GitHub OIDC token for Keycloak authentication
jobs:
test-jwt:
runs-on: ubuntu-24.04-arm
environment:
name: test-keycloak
deployment: false
env:
DEPLOY_ENV: test-keycloak
KEYCLOAK_URL: ${{ vars.KEYCLOAK_URL }}
KEYCLOAK_REALM: ${{ vars.KEYCLOAK_REALM }}
KEYCLOAK_AUDIENCE: "${{ vars.KEYCLOAK_URL }}${{ vars.KEYCLOAK_BASE_PATH || '' }}/realms/${{ vars.KEYCLOAK_REALM }}"
KEYCLOAK_BASE_PATH: ${{ vars.KEYCLOAK_BASE_PATH || '' }} # legacy path /auth
steps:
- uses: actions/checkout@v4
- id: keycloak-token
name: Get KC token
uses: .https://github.com/AleksandrSor/demo-infra/blob/main/.github/actions/keycloak-token
with:
keycloak-url: ${{ env.KEYCLOAK_URL }}
keycloak-realm: ${{ env.KEYCLOAK_REALM }}
keycloak-client-id: repo:${{ github.repository }}:environment:${{ env.DEPLOY_ENV }}
keycloak-base-path: ${{ env.KEYCLOAK_BASE_PATH || ''}}
- name: Test KC token
run: |
KEYCLOAK_RESPONSE=$(curl -s "${KEYCLOAK_URL}${KEYCLOAK_BASE_PATH}/realms/$KEYCLOAK_REALM/protocol/openid-connect/userinfo" \
-H "Authorization: Bearer ${{ steps.keycloak-token.outputs.id-token }}" | jq -r '.')
echo "----"
echo "${KEYCLOAK_RESPONSE}"
echo "----"
- name: Admin Test KC token
run: |
KEYCLOAK_RESPONSE=$(curl -s "${KEYCLOAK_URL}${KEYCLOAK_BASE_PATH}/admin/realms/$KEYCLOAK_REALM" \
-H "Authorization: Bearer ${{ steps.keycloak-token.outputs.access-token }}" \
-H "Accept: application/json" | jq -r '.')
echo "----"
echo "${KEYCLOAK_RESPONSE}"
echo "----"
Output:
Run KEYCLOAK_RESPONSE=$(curl -s "${KEYCLOAK_URL}${KEYCLOAK_BASE_PATH}/realms/$KEYCLOAK_REALM/protocol/openid-connect/userinfo" \
----
{
"sub": "2a428d3a-4359-49f4-abcd-607fdb23aa8f",
"email_verified": false,
"preferred_username": "service-account-repo:aleksandrsor/demo-infra:environment:test-keycloak"
}
Keycloak Terraform/OpenTofu provider
Let’s put everything together in the IaC workflow. The provider documentation is available here and explains how to set up credentials to manage a Keycloak instance.
Keep in mind that there is an initial chicken-and-egg problem: you need to run the Keycloak stack outside the GitHub workflow first so you can bootstrap the client used for GitHub Actions workflows.
name: deploy-IaC
on:
workflow_call:
permissions:
contents: read
id-token: write
jobs:
deploy:
name: IaC Deploy
environment:
name: production
deployment: true
runs-on: ubuntu-24.04-arm
env:
DEPLOY_ENV: production
KEYCLOAK_URL: ${{ vars.KEYCLOAK_URL }}
KEYCLOAK_REALM: ${{ vars.KEYCLOAK_REALM }}
KEYCLOAK_BASE_PATH: ${{ vars.KEYCLOAK_BASE_PATH || ''}}
defaults:
run:
working-directory: ${{ env.working_dir }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
...
- id: keycloak-token
name: Get KC token
uses: .https://github.com/AleksandrSor/demo-infra/blob/main/.github/actions/keycloak-token
with:
keycloak-url: ${{ env.KEYCLOAK_URL }}
keycloak-realm: ${{ env.KEYCLOAK_REALM }}
keycloak-client-id: repo:${{ github.repository }}:environment:${{ env.DEPLOY_ENV }}
keycloak-base-path: ${{ env.KEYCLOAK_BASE_PATH || ''}}
- name: Apply terragrunt configuration
env:
KEYCLOAK_ACCESS_TOKEN: ${{ steps.keycloak-token.outputs.access-token }}
KEYCLOAK_CLIENT_ID: ${{ vars.KEYCLOAK_CLIENT_ID || 'admin-cli' }}
run: |
terragrunt run --all --non-interactive --log-format bare -- apply -auto-approve
The Keycloak provider can be configured through environment variables:
-
KEYCLOAK_URL: Base URL of the Keycloak server (for example,https://keycloak.example.com). -
KEYCLOAK_REALM: Realm -
KEYCLOAK_BASE_PATH: Optional base path for Keycloak endpoints (for example,/authfor legacy distributions). -
KEYCLOAK_CLIENT_ID: Client ID used by the provider to manage an instance (commonlyadmin-cli). -
KEYCLOAK_ACCESS_TOKEN: Access token obtained through token exchange.
Conclusion
I can manage the Keycloak instance without static credentials by using a token from the GitHub OIDC provider. I can also use Keycloak as an identity broker to exchange my GitHub Actions token for a Keycloak token and access resource servers in the same way when they accept OIDC tokens.
Top comments (0)