Files
home-network-k3s-impl/platform/keycloak/keycloak-realm-import.yaml
T
jakman85andClaude Opus 4.8 330c99eb8b feat(keycloak): enable offline_access for kubernetes OIDC client
Add the offline_access optional client scope to the `kubernetes`
kubelogin client so the operator's kubectl can request long-lived
OFFLINE refresh tokens (--oidc-extra-scope=offline_access) instead of
being forced to re-auth on the realm's ONLINE session timeouts
(ssoSessionIdleTimeout 30m / ssoSessionMaxLifespan 10h). Offline tokens
honour offlineSessionIdleTimeout 30d with no hard max lifespan.

- keycloak-realm-import.yaml: add optionalClientScopes: [offline_access]
  to the kubernetes client so a fresh realm import reproduces it.
- k8s-oidc-rbac.yaml: document the kubeconfig exec-arg
  (--oidc-extra-scope=offline_access) + clear-cache/re-login steps.

Realm session timeouts unchanged; scope is optional (opt-in) and
client-only. Applied live via kcadm (reversible).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 13:01:37 -05:00

390 lines
17 KiB
YAML

---
# Identity config as code (R-IDP-03): the `homelab` realm — groups, roles, OIDC clients, brute-force
# hardening, and a conditional MFA flow that REQUIRES OTP for platform-admins (R-IDP-04). Reconciled
# declaratively by the Keycloak Operator; CARRIED BY platform-config (dependsOn platform).
#
# Client secrets are NOT in this file: they are injected from the SOPS-encrypted keycloak-realm-clients
# Secret via spec.placeholders ($(env:...)) — so secrets exist only encrypted (R-IDP-04; SOPS, docs/07).
# Until that Secret is filled (operator hand-off, see keycloak-realm-clients.sops.yaml.example), this
# import stays PENDING on the unresolved placeholder.
apiVersion: k8s.keycloak.org/v2alpha1
kind: KeycloakRealmImport
metadata:
name: homelab
namespace: keycloak
spec:
keycloakCRName: keycloak
placeholders:
GRAFANA_CLIENT_SECRET:
secret:
name: keycloak-realm-clients
key: grafana-client-secret
OAUTH2_PROXY_CLIENT_SECRET:
secret:
name: keycloak-realm-clients
key: oauth2-proxy-client-secret
GITEA_CLIENT_SECRET:
secret:
name: keycloak-realm-clients
key: gitea-client-secret
NEXTCLOUD_CLIENT_SECRET:
secret:
name: keycloak-realm-clients
key: nextcloud-client-secret
SEMAPHORE_CLIENT_SECRET:
secret:
name: keycloak-realm-clients
key: semaphore-client-secret
HEADLAMP_CLIENT_SECRET:
secret:
name: keycloak-realm-clients
key: headlamp-client-secret
realm:
realm: homelab
enabled: true
displayName: "Homelab SSO"
loginWithEmailAllowed: true
registrationAllowed: false
resetPasswordAllowed: true
# Hardening (docs/15): brute-force detection ON; SSL required for external (edge-TLS) requests.
bruteForceProtected: true
permanentLockout: false
sslRequired: external
# --- Authorization model (R-IDP-06): groups drive access; platform-admins carry a realm role. ---
roles:
realm:
- name: platform-admin
description: "Privileged platform administrators — MFA required (R-IDP-04)."
groups:
- name: platform-admins
realmRoles:
- platform-admin
- name: media-users
- name: viewers
# --- OIDC clients (native SSO). Group membership is exposed as a `groups` claim so apps and
# oauth2-proxy can authorize by group (R-IDP-06). ---
clients:
# Grafana — the concrete native-OIDC SSO proof target (docs/10 / deliverable #4).
- clientId: grafana
name: Grafana
protocol: openid-connect
enabled: true
publicClient: false
standardFlowEnabled: true
directAccessGrantsEnabled: false
secret: "$(env:GRAFANA_CLIENT_SECRET)"
redirectUris:
- https://grafana.home.keithjackson.dev/login/generic_oauth
webOrigins:
- https://grafana.home.keithjackson.dev
defaultClientScopes:
- email
- profile
- roles
protocolMappers:
- name: groups
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
"full.path": "false"
"id.token.claim": "true"
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
# oauth2-proxy — forward-auth for non-OIDC UIs (PiHole, *arr, Prometheus, Alertmanager, …).
- clientId: oauth2-proxy
name: oauth2-proxy (forward-auth)
protocol: openid-connect
enabled: true
publicClient: false
standardFlowEnabled: true
directAccessGrantsEnabled: false
secret: "$(env:OAUTH2_PROXY_CLIENT_SECRET)"
redirectUris:
- https://*.home.keithjackson.dev/oauth2/callback
- https://auth.home.keithjackson.dev/oauth2/callback
webOrigins:
- https://*.home.keithjackson.dev
defaultClientScopes:
- email
- profile
- roles
protocolMappers:
- name: groups
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
"full.path": "false"
"id.token.claim": "true"
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
# kubernetes — kubectl OIDC login via kubelogin (int128/kubelogin `oidc-login`). PUBLIC client +
# PKCE (S256) so NO client secret lives in any user's kubeconfig; kubelogin runs the auth-code
# +PKCE flow against a localhost callback and caches the ID token. The k3s API server validates
# that ID token (kube-apiserver oidc-issuer-url=this realm, oidc-client-id=kubernetes,
# oidc-username-claim=email, oidc-groups-claim=groups); RBAC binds the `groups` claim — see
# k8s-oidc-rbac.yaml (oidc:platform-admins -> cluster-admin, oidc:viewers -> view). The cert
# kubeconfig remains the break-glass admin, independent of Keycloak/OIDC availability.
- clientId: kubernetes
name: Kubernetes (kubectl)
protocol: openid-connect
enabled: true
publicClient: true
standardFlowEnabled: true
directAccessGrantsEnabled: false
attributes:
"pkce.code.challenge.method": "S256"
redirectUris:
- http://localhost:8000
- http://localhost:8000/
- http://localhost:18000
- http://localhost:18000/
- http://127.0.0.1:8000
- http://127.0.0.1:8000/
- http://127.0.0.1:18000
- http://127.0.0.1:18000/
webOrigins:
- http://localhost
- http://127.0.0.1
defaultClientScopes:
- email
- profile
- roles
# offline_access (optional): lets kubelogin request a long-lived OFFLINE refresh token via
# `--oidc-extra-scope=offline_access`, so the operator's kubectl isn't forced to re-auth on the
# ONLINE session idle/max timeouts (ssoSessionIdleTimeout 30m / ssoSessionMaxLifespan 10h).
# Offline tokens honour offlineSessionIdleTimeout (30d) with offlineSessionMaxLifespanEnabled=false
# (no hard cap) — see k8s-oidc-rbac.yaml header for the kubeconfig exec-arg. Optional (not default)
# so it is opt-in per the client's request scopes; realm session timeouts stay unchanged.
optionalClientScopes:
- offline_access
protocolMappers:
- name: groups
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
"full.path": "false"
"id.token.claim": "true"
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
# gitea — native OIDC SSO for the bootstrapping-critical git host (catalog/gitea.yaml auth.mode
# oidc; docs/13 SSO row, docs/15 "Native OIDC"; R-IDP-02). Confidential auth-code client modeled on
# grafana. Redirect URI is Gitea's OAuth2 callback for the auth source named `keycloak`
# (/user/oauth2/<source>/callback). The `groups` claim drives authorization: Gitea maps the
# platform-admins group -> Gitea admin (auth source --admin-group; R-IDP-06). The LOCAL Gitea admin
# remains the break-glass path (docs/15 availability caveat). Secret injected from the SOPS
# keycloak-realm-clients Secret via the GITEA_CLIENT_SECRET placeholder (R-IDP-04).
- clientId: gitea
name: Gitea
protocol: openid-connect
enabled: true
publicClient: false
standardFlowEnabled: true
directAccessGrantsEnabled: false
secret: "$(env:GITEA_CLIENT_SECRET)"
redirectUris:
- https://gitea.home.keithjackson.dev/user/oauth2/keycloak/callback
webOrigins:
- https://gitea.home.keithjackson.dev
defaultClientScopes:
- email
- profile
- roles
protocolMappers:
- name: groups
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
"full.path": "false"
"id.token.claim": "true"
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
# nextcloud — native OIDC SSO for the file-sync host (catalog/nextcloud.yaml auth.mode oidc; docs/13
# SSO row, docs/15; R-IDP-02). Confidential auth-code client modeled on grafana. Redirect URI is the
# user_oidc app's callback (/apps/user_oidc/code). The `groups` claim drives group provisioning in
# Nextcloud (user_oidc --group-provisioning; R-IDP-06). The LOCAL Nextcloud admin stays break-glass
# (docs/15). Secret injected from the SOPS keycloak-realm-clients Secret via NEXTCLOUD_CLIENT_SECRET.
- clientId: nextcloud
name: Nextcloud
protocol: openid-connect
enabled: true
publicClient: false
standardFlowEnabled: true
directAccessGrantsEnabled: false
secret: "$(env:NEXTCLOUD_CLIENT_SECRET)"
redirectUris:
- https://nextcloud.home.keithjackson.dev/apps/user_oidc/code
webOrigins:
- https://nextcloud.home.keithjackson.dev
# Post-logout redirect allow-list (#logout "redirect failed"). On sign-out user_oidc redirects the
# browser to Keycloak's end_session_endpoint with post_logout_redirect_uri=<Nextcloud base '/'> and
# client_id=nextcloud but NO id_token_hint (provider sendIdTokenHint=0), so Keycloak validates the
# redirect against this client attribute — empty => "Invalid redirect uri" => the operator's
# "redirect failed". The wildcard covers the base URL and any logout landing path. Keycloak stores
# this as the client attribute `post.logout.redirect.uris` (## separates multiple values).
attributes:
post.logout.redirect.uris: https://nextcloud.home.keithjackson.dev/*
defaultClientScopes:
- email
- profile
- roles
protocolMappers:
- name: groups
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
"full.path": "false"
"id.token.claim": "true"
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
# semaphore — native OIDC SSO for the Ansible UI (catalog/semaphore.yaml auth.mode oidc; docs/13 SSO
# row, docs/15; R-IDP-02). Confidential auth-code client modeled on grafana. Redirect URI is
# Semaphore's OIDC callback (/api/auth/oidc/<provider>/redirect for provider `keycloak`). The
# `groups` claim is exposed for authorization (R-IDP-06). The LOCAL Semaphore admin stays break-glass
# (docs/15). Secret injected from the SOPS keycloak-realm-clients Secret via SEMAPHORE_CLIENT_SECRET.
- clientId: semaphore
name: Semaphore
protocol: openid-connect
enabled: true
publicClient: false
standardFlowEnabled: true
directAccessGrantsEnabled: false
secret: "$(env:SEMAPHORE_CLIENT_SECRET)"
redirectUris:
- https://semaphore.home.keithjackson.dev/api/auth/oidc/keycloak/redirect
webOrigins:
- https://semaphore.home.keithjackson.dev
defaultClientScopes:
- email
- profile
- roles
protocolMappers:
- name: groups
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
"full.path": "false"
"id.token.claim": "true"
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
# headlamp — the Kubernetes dashboard (platform/headlamp). Confidential auth-code client modeled
# on grafana. Redirect URI is Headlamp's OIDC callback (<base>/oidc-callback). Headlamp forwards
# the logged-in user's id_token to the k3s apiserver as the bearer, so RBAC is applied PER USER
# via the existing oidc:* group bindings (k8s-oidc-rbac.yaml) — NOT via Headlamp's ServiceAccount.
#
# AUDIENCE MAPPER (the key integration nuance): the apiserver is configured oidc-client-id=
# kubernetes, so it only accepts ID tokens whose `aud` CONTAINS `kubernetes`. A plain `headlamp`
# client issues aud=[headlamp] -> the apiserver would REJECT it. The oidc-audience-mapper below
# ADDS the `kubernetes` client to the audience, giving aud=[headlamp, kubernetes]: Headlamp's own
# verifier accepts it (aud contains headlamp) AND the apiserver accepts it (aud contains
# kubernetes). This keeps the apiserver's oidc config untouched (preferred over widening
# api-audiences). The `groups` mapper (same as the other clients) carries the group claim the
# apiserver maps to oidc:<group> for the RBAC bindings.
- clientId: headlamp
name: Headlamp (Kubernetes dashboard)
protocol: openid-connect
enabled: true
publicClient: false
standardFlowEnabled: true
directAccessGrantsEnabled: false
secret: "$(env:HEADLAMP_CLIENT_SECRET)"
redirectUris:
- https://headlamp.home.keithjackson.dev/oidc-callback
webOrigins:
- https://headlamp.home.keithjackson.dev
defaultClientScopes:
- email
- profile
- roles
protocolMappers:
- name: groups
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
"full.path": "false"
"id.token.claim": "true"
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
# Add `kubernetes` to the token audience so the k3s apiserver (oidc-client-id=kubernetes)
# accepts the forwarded id_token. `included.client.audience` names the existing `kubernetes`
# client; the mapper appends it to `aud` in the ID token (and access token, harmless).
- name: audience-kubernetes
protocol: openid-connect
protocolMapper: oidc-audience-mapper
config:
"included.client.audience": kubernetes
"id.token.claim": "true"
"access.token.claim": "true"
# --- MFA for platform-admins (R-IDP-04): a browser flow whose OTP step is CONDITIONAL on the
# platform-admin role. Non-admins log in without OTP; platform-admins must enroll/present TOTP.
# Set as the realm browserFlow below. VERIFY at bring-up (see runbook): a malformed flow fails
# the whole import — if so, the fallback is to comment this block + browserFlow and configure
# the conditional-OTP-by-role flow via the admin console. ---
authenticationFlows:
- alias: browser-mfa
description: "Browser flow with conditional OTP required for platform-admins."
providerId: basic-flow
topLevel: true
builtIn: false
authenticationExecutions:
- authenticator: auth-cookie
authenticatorFlow: false
requirement: ALTERNATIVE
priority: 10
userSetupAllowed: false
- authenticator: identity-provider-redirector
authenticatorFlow: false
requirement: ALTERNATIVE
priority: 20
userSetupAllowed: false
- authenticatorFlow: true
requirement: ALTERNATIVE
priority: 30
flowAlias: browser-mfa-forms
userSetupAllowed: false
- alias: browser-mfa-forms
description: "Username/password then conditional OTP."
providerId: basic-flow
topLevel: false
builtIn: false
authenticationExecutions:
- authenticator: auth-username-password-form
authenticatorFlow: false
requirement: REQUIRED
priority: 10
userSetupAllowed: false
- authenticatorFlow: true
requirement: CONDITIONAL
priority: 20
flowAlias: browser-mfa-conditional-otp
userSetupAllowed: false
- alias: browser-mfa-conditional-otp
description: "Require OTP only for users with the platform-admin role."
providerId: basic-flow
topLevel: false
builtIn: false
authenticationExecutions:
- authenticator: conditional-user-role
authenticatorFlow: false
requirement: REQUIRED
priority: 10
authenticatorConfig: conditional-otp-platform-admin
userSetupAllowed: false
- authenticator: auth-otp-form
authenticatorFlow: false
requirement: REQUIRED
priority: 20
userSetupAllowed: false
authenticatorConfig:
- alias: conditional-otp-platform-admin
config:
"condition.evaluated.user.role": platform-admin
negate: "false"
browserFlow: browser-mfa