--- # 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 CRAFTY_CLIENT_SECRET: secret: name: keycloak-realm-clients key: crafty-client-secret PIHOLE_CLIENT_SECRET: secret: name: keycloak-realm-clients key: pihole-client-secret JACKETT_CLIENT_SECRET: secret: name: keycloak-realm-clients key: jackett-client-secret QBITTORRENT_CLIENT_SECRET: secret: name: keycloak-realm-clients key: qbittorrent-client-secret RADARR_CLIENT_SECRET: secret: name: keycloak-realm-clients key: radarr-client-secret SONARR_CLIENT_SECRET: secret: name: keycloak-realm-clients key: sonarr-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//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= 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//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 (/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: 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" # crafty — the Minecraft management panel (platform/crafty). Crafty's web UI has NO native OIDC, # so it is gated by a PER-APP oauth2-proxy REVERSE-PROXY sidecar (Option B; platform/crafty + # platform/oauth2-proxy/README.md) — this is that sidecar's confidential auth-code client, modeled # on the grafana/oauth2-proxy clients. Redirect URI is the oauth2-proxy callback on Crafty's host. # The `groups` claim (bare names) lets the sidecar enforce --allowed-groups=platform-admins # (R-IDP-06). Secret injected from the SOPS keycloak-realm-clients Secret via CRAFTY_CLIENT_SECRET # (R-IDP-04); the SAME value is mirrored in platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml # (cross-namespace mirror, like grafana/gitea/headlamp). Provisioned live via kcadm at authoring # time (2026-08-28) and captured here as the durable GitOps source of truth. - clientId: crafty name: Crafty Controller (oauth2-proxy) protocol: openid-connect enabled: true publicClient: false standardFlowEnabled: true directAccessGrantsEnabled: false secret: "$(env:CRAFTY_CLIENT_SECRET)" redirectUris: - https://crafty.home.keithjackson.dev/oauth2/callback webOrigins: - https://crafty.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 # pihole — the DNS admin UI (catalog/pihole.yaml auth.mode forward-auth). PiHole's UI has NO # native OIDC, so it is gated by a PER-APP oauth2-proxy REVERSE-PROXY sidecar (Option B, rendered # by catalog/render.py) — this is that sidecar's confidential auth-code client, modeled on the # crafty/oauth2-proxy clients. Redirect URI is the oauth2-proxy callback on PiHole's host. The # `groups` claim (bare names) lets the sidecar enforce --allowed-group=platform-admins (R-IDP-06). # Secret injected from the SOPS keycloak-realm-clients Secret via PIHOLE_CLIENT_SECRET (R-IDP-04); # the SAME value is mirrored in apps/wave-0/pihole-oauth2-proxy-credentials.sops.yaml (cross- # namespace mirror). Provisioned live via kcadm at authoring time (2026-08-31) and captured here # as the durable GitOps source of truth (the operator imports the realm once; kcadm is authoritative). - clientId: pihole name: PiHole (oauth2-proxy) protocol: openid-connect enabled: true publicClient: false standardFlowEnabled: true directAccessGrantsEnabled: false secret: "$(env:PIHOLE_CLIENT_SECRET)" redirectUris: - https://pihole.home.keithjackson.dev/oauth2/callback webOrigins: - https://pihole.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 # jackett/qbittorrent/radarr/sonarr — media stack UIs (catalog/*.yaml auth.mode forward-auth, # auth.groups media-users). Each has a bare UI with no usable native OIDC, gated by a per-app # oauth2-proxy REVERSE-PROXY sidecar (Option B, rendered by catalog/render.py) — these are those # sidecars' confidential auth-code clients, modeled on the crafty/pihole clients. Redirect URI is # the oauth2-proxy callback on each app's host; the `groups` mapper (bare names) lets the sidecar # enforce --allowed-group=media-users (R-IDP-06). Secrets injected from the SOPS keycloak-realm- # clients Secret via the *_CLIENT_SECRET placeholders (R-IDP-04); the SAME values are mirrored in # apps/wave-*/-oauth2-proxy-credentials.sops.yaml. Provisioned live via kcadm 2026-08-31 and # captured here as the durable GitOps source of truth (operator imports once; kcadm authoritative). - clientId: jackett name: Jackett (oauth2-proxy) protocol: openid-connect enabled: true publicClient: false standardFlowEnabled: true directAccessGrantsEnabled: false secret: "$(env:JACKETT_CLIENT_SECRET)" redirectUris: - https://jackett.home.keithjackson.dev/oauth2/callback webOrigins: - https://jackett.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 - clientId: qbittorrent name: qBittorrent (oauth2-proxy) protocol: openid-connect enabled: true publicClient: false standardFlowEnabled: true directAccessGrantsEnabled: false secret: "$(env:QBITTORRENT_CLIENT_SECRET)" redirectUris: - https://qbittorrent.home.keithjackson.dev/oauth2/callback webOrigins: - https://qbittorrent.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 - clientId: radarr name: Radarr (oauth2-proxy) protocol: openid-connect enabled: true publicClient: false standardFlowEnabled: true directAccessGrantsEnabled: false secret: "$(env:RADARR_CLIENT_SECRET)" redirectUris: - https://radarr.home.keithjackson.dev/oauth2/callback webOrigins: - https://radarr.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 - clientId: sonarr name: Sonarr (oauth2-proxy) protocol: openid-connect enabled: true publicClient: false standardFlowEnabled: true directAccessGrantsEnabled: false secret: "$(env:SONARR_CLIENT_SECRET)" redirectUris: - https://sonarr.home.keithjackson.dev/oauth2/callback webOrigins: - https://sonarr.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 # --- 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: # Keycloak's conditional-user-role authenticator reads the role from `condUserRole` # (ConditionalUserRoleAuthenticatorFactory.CONDITIONAL_USER_ROLE). The prior key # "condition.evaluated.user.role" is not a real property, so the role resolved to null → # "Invalid role name submitted: null" → the REQUIRED conditional-OTP step threw and blocked # every platform-admins login (e.g. Headlamp) with a 403. Value is the realm role name. condUserRole: platform-admin negate: "false" browserFlow: browser-mfa