How to Use Keycloak SSO for My Homelab Applications

In my previous post, I explained how I manage my server declaratively. As part of that setup, I use Keycloak as the Identity Provider to enable Single Sign-On for applications running on my homelab cluster via the OpenID Connect Authorization Code Flow. In today’s post, I’ll walk you through how to configure Keycloak to secure these homelab applications.

AuthN and AuthZ

Before we start, it’s important to understand the concepts of authentication and authorization, as well as Keycloak’s role in managing them. Authentication verifies a user’s identity, ensuring they are who they claim to be. Authorization on the other hand, determines what actions or resources a user is allowed to access after their identity has been authenticated.

Keycloak will be used for:

  • User Management
  • JWT Token Issuance
  • Injecting Token Claims for application-specific access control

In the Authorization Code Flow, users are redirected to the Keycloak login page to authenticate and provide consent. Upon successful authentication, Keycloak returns an id_token and an access_token to the application. The application validates the token signature and uses the claims (e.g., roles, groups) in the payload to grant or restrict access. Configuring SSO for different applications requires understanding the claims and values expected by each application and mapping them to internal roles.

Let’s explore how to configure Keycloak for each application.

Keycloak

Clients

In Keycloak, a client represents an application or service that interacts with the Keycloak server. While it’s technically possible to use a shared client for multiple applications, the recommended best practice is to create a dedicated client for each application.

In this post, we’ll only use authorization code flow for all applications hence the client config is the same for all applications. The client configuration is managed using Terraform as below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
resource "keycloak_openid_client" "<app_name>" {
realm_id = keycloak_realm.terraform.id
client_id = "<app_name>"
name = "lab-<app_name>-login"
description = "A client for homelab <app_name> OIDC log in"
enabled = true
access_type = "CONFIDENTIAL"
valid_redirect_uris = [
"<app_redirect_url>"
]
web_origins = [
"https://<app_domain>"
]
client_secret = data.sops_file.secrets.data["<app_name>>.client_secret"]
standard_flow_enabled = true
}

The setup is pretty straightforward. I only enable authorization code flow for each app, use the app name as the client_id, generate the client_secret locally, encrypt it with SOPS, and pass it to Terraform for creation in Keycloak.

Users, Groups, Roles, Claims and Scopes

Keycloak is also used for user management. To understand how authorization works, it’s essential to grasp the following concepts:

  • Users: Represent individuals authenticating with Keycloak. For my homelab setups, as I am the only user, I can use User-Based Access Control to have permissions assigned directly to my users.
  • Roles: Define permissions and are commonly used in Role-Based Access Control to to restrict system access to authorized users based on their roles.
  • Groups: Organize users into subsets that can inherit roles and permission automatically.
  • Claims: Contain user information (e.g., roles, groups) and are included in issued JWTs or introspection results.
  • Scopes: Simply put are groups of claims. Clients request specific scopes, and the associated claims are added to tokens.

In summary, users are added to groups, permissions are assigned to roles, groups inherit roles, and scopes determine which claims are injected in tokens. Let’s see how these concepts are applied in each application.

Hashicorp Vault

The first application we will check out is hashicorp vault and we will start simple by using user-based access control meaning the permission will be assigned to one particular user only.

Auth backend

On the vault side, we will create a jwt auth backend as below.

One advantage of self-hosting Keycloak is full control over clients, for example I can declare client_id and client_secret.

In this example, we grant the vault-admin user the admin role by mapping the preferred_username claim in Keycloak-issued tokens to the user_claim in vault. The bound_claims ensure only the vault-admin user can use the Vault admin role.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
resource "vault_jwt_auth_backend" "oidc" {
path = "oidc"
type = "oidc"
default_role = "admin"
oidc_discovery_url = "https://<keycloak>/realms/<realm>"
oidc_client_id = data.sops_file.kv-secrets.data["oidc.vault.client_id"]
oidc_client_secret = data.sops_file.kv-secrets.data["oidc.vault.client_secret"]
tune {
listing_visibility = "unauth"
}
}

resource "vault_jwt_auth_backend_role" "admin" {
backend = vault_jwt_auth_backend.oidc.path
role_name = "admin"
token_policies = ["admin"]
user_claim = "preferred_username"
bound_claims = {
"preferred_username" = "vault-admin"
}
role_type = "oidc"
allowed_redirect_uris = [
"https://<vault>:8200/ui/vault/auth/oidc/oidc/callback"
]
}

resource "vault_policy" "admin" {
name = "admin"
policy = data.vault_policy_document.admin.hcl
}

Keycloak User and client

Redirect URL for Hashicorp Vault client:

1
2
3
4
5
6
valid_redirect_uris = [
"https://<vault>:8200/ui/vault/auth/oidc/oidc/callback"
]
web_origins = [
"https://<vault>:8200"
]

On the keycloak side, create the vault-admin user and make sure the valid_redirect_uris and web_origins are updated accordingly.

1
2
3
4
5
6
7
8
9
10
11
12
13
resource "keycloak_user" "vault-admin" {
realm_id = keycloak_realm.terraform.id
username = "vault-admin"
enabled = true
email_verified = true
email = "vault-admin@li.local"
first_name = "Vault"
last_name = "Admin"
initial_password {
value = data.sops_file.secrets.data["users.vault-admin.password"]
temporary = false
}
}

Now, we can log in to the HashiCorp Vault UI using OIDC.

Grafana

Next, we’ll configure Keycloak for Grafana, using Role-Based Access Control.

Role Mapping

Let’s go back to the official doc and we can see Grafana maps roles based on the roles claim in the id_token. The example mapping is as follows:

1
role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer'

This mapping assigns Grafana role based on roles claim follows below priority:

  1. If roles contains grafanaadmin -> assign GrafanaAdmin.
  2. Else if roles contains admin -> assign Admin.
  3. Else if roles contains editor -> assign Editor.
  4. If none of those match -> assign Viewer.

For simplicity, I’ll use only the GrafanaAdmin role. Below is part of my helm values.yaml file for OIDC SSO.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
grafana.ini:
auth.generic_oauth:
name: Keycloak
enabled: true
allow_sign_up: true
scopes: openid offline_access profile email
auth_url: https://<keycloak>/realms/<keycloak>/protocol/openid-connect/auth
token_url: https://<keycloak>/realms/<keycloak>/protocol/openid-connect/token
api_url: https://<keycloak>/realms/<keycloak>/protocol/openid-connect/token/introspect
signout_redirect_url: https://<keycloak>/realms/<keycloak>/protocol/openid-connect/logout?post_logout_redirect_uri=https%3A%2F%2F<grafana_domain>%2Flogin
role_attribute_strict: true
role_attribute_path: contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin'
allow_assign_grafana_admin: true
skip_org_role_sync: false
tls_client_ca: /etc/secrets/homelab/ca.crt
use_refresh_token: true

Keycloak role and group setup

Redirect URL for grafana client:

1
2
3
4
5
6
valid_redirect_uris = [
"https://<grafana>/login/generic_oauth"
]
web_origins = [
"https://<grafana>"
]

In Keycloak, create the following entities:

  • role: grafanaadmin
  • user: grafana-admin
  • group: grafana_admins
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
resource "keycloak_role" "grafanaadmin" {
realm_id = keycloak_realm.terraform.id
client_id = keycloak_openid_client.grafana.id
name = "grafanaadmin"
}

resource "keycloak_user" "grafana-admin" {
realm_id = keycloak_realm.terraform.id
username = "grafana-admin"
enabled = true
email_verified = true
email = "grafana-admin@li.local"
first_name = "grafana"
last_name = "Admin"
initial_password {
value = data.sops_file.secrets.data["users.grafana-admin.password"]
temporary = false
}
}

resource "keycloak_group" "grafana_admins" {
realm_id = keycloak_realm.terraform.id
name = "grafana_admins"
}

Then I add user grafana-admin to the grafana_admins group and assign role grafanaadmin to the group. This setup ensures that any user added to the grafana_admins group automatically inherits grafanaadmin role.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
resource "keycloak_group_memberships" "grafana_admins_group_members" {
realm_id = keycloak_realm.terraform.id
group_id = keycloak_group.grafana_admins.id

members = [
keycloak_user.grafana-admin.username,
]
}

resource "keycloak_group_roles" "grafana_admins_roles" {
realm_id = keycloak_realm.terraform.id
group_id = keycloak_group.grafana_admins.id

role_ids = [
keycloak_role.grafanaadmin.id,
]
}

The last step is to add the roles claim to the grafana client. Since Grafana check roles claim from id_token, we add this claim to id_token only.

1
2
3
4
5
6
7
8
9
10
resource "keycloak_openid_user_client_role_protocol_mapper" "grafana_roles_claim" {
realm_id = keycloak_realm.terraform.id
client_id = keycloak_openid_client.grafana.id
name = "client-roles"
claim_name = "roles"
add_to_id_token = true
add_to_access_token = false
add_to_userinfo = false
multivalued = true
}

Once configured, we can log in to Grafana via Keycloak.

ArgoCD

The next application is ArgoCD, where we’ll explore group-based access control using scopes.

Group mapping

ArgoCD has probably the best documentation among these three apps.

In this given example, grafana map admin role to all users in the ArgoCDAdmins group.

1
2
3
4
5
6
7
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-rbac-cm
data:
policy.csv: |
g, ArgoCDAdmins, role:admin

To enable this, we need to make claim is added to id_token. Below is my OIDC configuration.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
cm = {
create = true;
admin.enabled = "false";
url = "https://<argocd>";
"oidc.config" = ''
name: Keycloak
issuer: https://<keycloak>/realms/<realm>
clientID: argocd
clientSecret: $argocd-oidc:keycloak.clientSecret
rootCA: |-
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
requestedIDTokenClaims:
groups:
essential: true
requestedScopes:
- openid
- profile
- email
- groups
'';
};

We can see the requestedScopes are openid profile email and groups. The groups scope is not included by default and let’s see how we can add this scope to our client.

Keycloak group and scope set up

Redirect URL for argocd client:

1
2
3
4
5
6
valid_redirect_uris = [
"https://<argocd>/auth/callback",
]
web_origins = [
"https://<argocd>"
]

Let’s create the ArgoCDAdmins group, create and add the argocd-admin user to it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
resource "keycloak_user" "argocd-admin" {
realm_id = keycloak_realm.terraform.id
username = "argocd-admin"
enabled = true
email_verified = true
email = "argocd-admin@li.local"
first_name = "argocd"
last_name = "Admin"
initial_password {
value = data.sops_file.secrets.data["users.argocd-admin.password"]
temporary = false
}
}

resource "keycloak_group" "argocd_admin_group" {
realm_id = keycloak_realm.terraform.id
name = "ArgoCDAdmins"
}

resource "keycloak_group_memberships" "argocd_admin_members" {
realm_id = keycloak_realm.terraform.id
group_id = keycloak_group.argocd_admin_group.id

members = [
keycloak_user.argocd-admin.username,
]
}

Next we will create groups scope and map groups claim to it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
resource "keycloak_openid_client_scope" "groups_scope" {
realm_id = keycloak_realm.terraform.id
name = "groups"
description = "Groups scope for accessing user group memberships"

consent_screen_text = "Group memberships"
include_in_token_scope = true
}

resource "keycloak_openid_group_membership_protocol_mapper" "groups_scope_mapper" {
realm_id = keycloak_realm.terraform.id
client_scope_id = keycloak_openid_client_scope.groups_scope.id
name = "groups-mapper"
claim_name = "groups"
full_path = false
add_to_id_token = true
add_to_access_token = false
add_to_userinfo = false
}

Lastly let’s add groups scope as optional to argocd client.

1
2
3
4
5
6
7
8
resource "keycloak_openid_client_optional_scopes" "argocd_groups_optional_scope" {
realm_id = keycloak_realm.terraform.id
client_id = keycloak_openid_client.argocd.id

optional_scopes = [
keycloak_openid_client_scope.groups_scope.name,
]
}

This is how we add groups scopes to the client. Now ArgoCD will include the groups claim in the id_token and map it to roles accordingly.

Other frontends

For applications without built-in OIDC support, you can offload authentication to an API gateway like Kong which is sitting in front of your application.

Kong OpenID Connect Plugin

Below is an example configuration to use the Kong OpenID Connect plugin.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
plugins:
- name: openid-connect
config:
issuer: "https://<keycloak>/realms/<realm>"
client_id:
- "orders"
client_secret:
- "{vault://hcv/kong/oidc/orders/client_secret}"
auth_methods:
- authorization_code
- session
consumer_claim:
- preferred_username
session_storage: cookie
  • issuer: Auto discovery the oidc well known endpoint and validate token issuer.
  • client_id and client_secret: This is required to redeem the access token.
  • auth_methods: We tells the plugin to use authorization_code and session authentication.
  • session_storage: We store session data in cookie. If you have multiple data planes, you might want to store the session information in a shared redis instance.
  • consumer_claim: This is used to map Kong consumers to Keycloak user.

Since I only have one user, I can rely on consumer_claim to look for the consumers. Let’s need to create a Kong consumer which has the same username as the keycloak username.

1
2
consumers:
- username: orders-admin

If you want to grant access to a group of users without creating Kong consumers, ACL plugin can be used with Kong OIDC plugin. For more information, please check this post

Keycloak User and client

Redirect URL for my orders app client:

1
2
3
4
5
6
valid_redirect_uris = [
"https://<orders>/",
]
web_origins = [
"https://<orders>"
]

On the keycloak side, we just need to create the user called orders-admin.

1
2
3
4
5
6
7
8
9
10
11
12
13
resource "keycloak_user" "orders-admin" {
realm_id = keycloak_realm.terraform.id
username = "orders-admin"
enabled = true
email_verified = true
email = "orders-admin@li.local"
first_name = "orders"
last_name = "Admin"
initial_password {
value = data.sops_file.secrets.data["users.orders-admin.password"]
temporary = false
}
}

That’s all the configuration required. When you access your application at https://<orders>/, Kong will redirect the request to Keycloak for authentication. Keycloak will validate the user’s credentials and return id_token and access_token, which Kong uses to ensure the correct consumer is accessing your application.

With the Kong OpenID Connect plugin, you can focus on your application’s business logic, leveraging the token passed through by Kong. (Kong forwards the access_token to the upstream service.)

That’s all I want to share with you today. See you in the next one.