Files
home-network-k3s-impl/platform/keycloak/keycloak-realm-import.yaml
T
jakman85andClaude Opus 5 16894b7b73
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
feat(keycloak): declare passkey (WebAuthn passwordless) login for the homelab realm
Adds the realm's WebAuthn Passwordless policy and restructures the browser-mfa
flow so a passkey is an ALTERNATIVE beside the password form. Additive and
optional by design: this IdP gates every SSO app plus kubectl, so a mandatory
passkey flow that misfires is a total lock-out. Enrolment is not forced.

Policy: RP ID is ${BASE_DOMAIN} (not the keycloak host) because a passkey's RP ID
is fixed at enrolment and must not be bound to a name that can move; resident key
required (a non-discoverable credential is a second factor, not a passkey); user
verification required (without it, possession alone is a complete login).

Flow: auth-username-password-form is split into auth-username-form +
auth-password-form, because the server must know who is logging in before it can
decide whether a passkey exists. Conditional OTP for platform-admins is relocated
intact onto the password branch only — a UV-required passkey is already two
phishing-resistant factors.

No requiredActions block is declared, deliberately: Keycloak 26.0.6 treats
requiredActions as REPLACE not merge, so declaring one would delete the default
set including CONFIGURE_TOTP and lock every platform-admin out of the OTP step
this flow still enforces. webauthn-register-passwordless is already registered
enabled-but-not-default by Keycloak's own defaults.

UNAPPLIED: the realm import is a one-shot resting at Done=True/Started=False, so
this reaches the live realm never. See the handoff entry and the README section.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-17 23:23:36 -05:00

879 lines
47 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
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
# ===========================================================================================
# PASSKEYS — the WebAuthn PASSWORDLESS policy (R-IDP-04). 2026-10-17.
# ===========================================================================================
# Read this together with the browser-mfa flow at the bottom of the file: THIS block decides
# what a passkey must BE, the FLOW decides when one is OFFERED. Keycloak keeps TWO independent
# WebAuthn policies per realm and they are not interchangeable:
#
# webAuthnPolicy* -> the TWO-FACTOR policy, consumed by the `webauthn-authenticator`
# authenticator (a security key presented AFTER a password).
# webAuthnPolicyPasswordless* -> the PASSWORDLESS policy, consumed by
# `webauthn-authenticator-passwordless` (a passkey presented
# INSTEAD of a password). This is the one passkeys use.
#
# Setting the wrong family is a silent no-op: the passwordless authenticator reads
# getWebAuthnPolicyPasswordless() only (WebAuthnPasswordlessAuthenticator.getWebAuthnPolicy),
# so a policy written into the two-factor fields is simply never consulted by a passkey login.
# Only the passwordless family is declared here; the two-factor family is deliberately left at
# Keycloak's defaults because nothing in this realm uses `webauthn-authenticator` — TOTP
# (auth-otp-form) is the second factor for platform-admins, see the flow section.
#
# ADDITIVE AND OPTIONAL — THE DECISION THIS BLOCK EXISTS TO MAKE
# -------------------------------------------------------------
# Passkeys are offered ALONGSIDE the password, never instead of it, and enrolment is NOT forced.
# This is a single-operator homelab whose IdP gates Grafana, Gitea, Nextcloud, Headlamp,
# Semaphore, PiHole, Crafty, the *arr stack AND kubectl itself (the `kubernetes` client below).
# A mandatory passkey flow that misfires is therefore not an inconvenience, it is a total
# lock-out from the whole platform AND from the cluster API in one step — and the only repair
# paths left would be the Keycloak bootstrap admin and the break-glass cert kubeconfig. The
# expected gain from forcing enrolment (one human stops typing a password) is small; the
# expected loss is the platform. So: additive, optional, and password login keeps working for
# every user who never enrols.
#
# Concretely that means: no `requiredActions` block is declared anywhere in this file (see the
# long note in the flow section for why declaring one would be actively DANGEROUS), and the
# passkey step sits as an ALTERNATIVE beside the password form rather than ahead of it.
#
# RELYING-PARTY ID — THE FIELD THAT SILENTLY BREAKS EVERY PASSKEY IF IT IS WRONG
# ------------------------------------------------------------------------------
# The RP ID is baked into every credential at enrolment and can never be changed afterwards:
# a passkey created under one RP ID is unusable under another, with no error the user can act
# on — the browser simply reports that no credential is available. It is also browser-enforced,
# so it cannot be "fixed" server-side: per WebAuthn, the RP ID must be equal to, or a
# registrable-domain suffix of, the origin's effective domain. Keycloak is served at
# keycloak.<BASE_DOMAIN> (httproute.yaml, and the Keycloak CR's hostname), so the legal choices
# are keycloak.<BASE_DOMAIN>, <BASE_DOMAIN>, or the registrable domain one level up. It is NOT
# the full URL and NOT a scheme — "https://..." here would fail every ceremony.
#
# We choose BASE_DOMAIN, the shared platform suffix, NOT the keycloak host, for one concrete
# reason drawn from this repo's own history: a credential that cannot be re-keyed must not be
# bound to a name that can move. The oauth2-proxy client below ALREADY declares a redirect URI
# on auth.<BASE_DOMAIN>, i.e. this platform already contemplates serving auth on a second
# hostname; and the client-URL reconciler exists precisely because moving a hostname in git
# silently fails to reach the realm (see README, "Client URLs: the same one-shot failure").
# Binding passkeys to the keycloak subdomain would mean that relocating Keycloak invalidates
# every enrolled passkey; binding them to BASE_DOMAIN survives it. The cost is scope: any
# future WebAuthn relying party under this base domain could assert the same RP ID. In a
# homelab where every such party is already behind THIS IdP that is not a trust boundary worth
# buying. Note the residual, unavoidable risk: changing BASE_DOMAIN itself invalidates every
# passkey, which is a re-enrolment, not a repair.
#
# Written as the cluster-var so it is substituted by the same Flux postBuild pass, with the same
# value, as every hostname in this file — there is no second place the domain is decided.
webAuthnPolicyPasswordlessRpId: ${BASE_DOMAIN}
# The human-readable name the browser/OS shows in the passkey prompt and stores beside the
# credential ("Save a passkey for Homelab SSO?"). Matches the realm displayName above so the
# OS keychain entry is recognisable. It is also load-bearing on the UPDATE path: Keycloak's
# updateRealm applies the passwordless policy ONLY when this field is non-empty
# (DefaultExportImportManager line ~842), so an admin-API realm update that omits it drops the
# whole policy silently.
webAuthnPolicyPasswordlessRpEntityName: "Homelab SSO"
# ES256 then RS256 — exactly Keycloak's own DEFAULT_POLICY, declared explicitly rather than
# inherited so the realm's crypto is auditable from git. ES256 is what every platform
# authenticator (Touch ID/Face ID, Windows Hello, Android) and every FIDO2 key produces;
# RS256 is the fallback for older Windows Hello TPM-backed credentials. EdDSA/Ed25519 is
# deliberately NOT offered: authenticator and browser support is uneven, and an authenticator
# that picks an algorithm the verifier handles badly fails at enrolment, which is the worst
# moment to discover it. Order is a preference list, not an allow-list ordering requirement.
webAuthnPolicyPasswordlessSignatureAlgorithms:
- ES256
- RS256
# DISCOVERABLE CREDENTIAL REQUIRED. "Yes" is a Keycloak sentinel, not a boolean, and the login
# theme compares it as the literal string: webauthnRegister.js maps 'Yes' to
# authenticatorSelection.requireResidentKey=true and ANY other non-sentinel value to false.
# This is not optional for passkeys — a non-discoverable credential cannot be found by the
# browser without the server first naming it, so it is a second factor, not a passkey.
# (Keycloak 26.0.6 emits only the LEGACY requireResidentKey member, not the newer
# residentKey:"required"; per WebAuthn L2 the client maps requireResidentKey=true onto
# residentKey="required", so discoverable credentials are genuinely requested — but it also
# means this policy cannot express residentKey:"preferred" at all.)
webAuthnPolicyPasswordlessRequireResidentKey: "Yes"
# USER VERIFICATION REQUIRED. The authenticator must verify the human (biometric or PIN) and
# say so in the assertion. This is the field that makes a passkey login at least as strong as
# the password it replaces: with verification merely "preferred", possession of an unlocked
# phone or an unattended security key would be a COMPLETE login to every SSO-gated app — a
# single-factor possession check, strictly weaker than the password+TOTP path beside it.
# Required makes the passkey branch inherently two-factor (something you have + something you
# are/know), which is the premise the flow section relies on when it does NOT ask a
# passkey-authenticated platform-admin for TOTP as well.
webAuthnPolicyPasswordlessUserVerificationRequirement: required
# "not specified" is Keycloak's sentinel for "either kind of authenticator"
# (Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED; the theme JS omits the WebAuthn member
# entirely when it sees this exact string). Deliberately NOT narrowed: "platform" would reject
# every YubiKey and every phone used as a cross-device passkey, and "cross-platform" would
# reject Touch ID and Windows Hello — i.e. either choice silently halves the usable hardware.
# The operator should be able to enrol a laptop AND a phone AND a hardware key.
webAuthnPolicyPasswordlessAuthenticatorAttachment: "not specified"
# ATTESTATION: none. Requesting "direct" would hand Keycloak an attestation statement it has
# no trust anchors to validate here (no metadata service is configured) and nothing to compare
# it against, since the AAGUID allow-list below is deliberately empty — so the statement would
# be collected, unverifiable, and would add an extra "share model information?" consent prompt
# on Safari/iOS for no security gain. Attestation is how you ENFORCE an approved-hardware list;
# we do not have one, so asking for it is cost without benefit.
webAuthnPolicyPasswordlessAttestationConveyancePreference: none
# Empty = accept any authenticator model. An AAGUID allow-list is a hardware whitelist: it
# would have to be maintained by hand, and every new phone, laptop or key the operator buys
# would be rejected at enrolment with a message that does not explain itself. It is also moot
# while attestation is "none" (above) — with no attestation statement there is no trustworthy
# AAGUID to match against in the first place.
webAuthnPolicyPasswordlessAcceptableAaguids: []
# Ceremony timeout in SECONDS (the login theme multiplies by 1000; 0 means "send no timeout and
# let the browser decide"). 300s is chosen over a tighter value because the slowest LEGITIMATE
# path is the cross-device hybrid flow — Keycloak renders a QR code, the operator unlocks a
# phone, scans it, and completes a biometric check — which routinely takes over a minute. A
# 60-120s timeout looks tidy and converts that flow into an unexplained failure. Browsers clamp
# this value (Chrome to at most 10 minutes), so 300 is honoured rather than reinterpreted.
webAuthnPolicyPasswordlessCreateTimeout: 300
# Send the user's existing passkeys as excludeCredentials at enrolment, so re-enrolling an
# authenticator the user already registered is refused by the authenticator itself instead of
# producing a second, indistinguishable credential in the account console. Cheap, and it keeps
# "which passkey is which" answerable — which matters when the recovery story is "delete the
# lost one".
webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister: true
# webAuthnPolicyPasswordlessExtraOrigins is intentionally NOT set. It exists to admit non-web
# origins (native app facet IDs, e.g. android:apk-key-hash:...). Every ceremony here happens in
# a browser on Keycloak's own origin, which is already covered by the RP ID above; adding
# origins is only ever a widening of who may ask for these credentials.
# --- 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.${BASE_DOMAIN}/login/generic_oauth
webOrigins:
- https://grafana.${BASE_DOMAIN}
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://*.${BASE_DOMAIN}/oauth2/callback
- https://auth.${BASE_DOMAIN}/oauth2/callback
webOrigins:
- https://*.${BASE_DOMAIN}
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.${BASE_DOMAIN}/user/oauth2/keycloak/callback
webOrigins:
- https://gitea.${BASE_DOMAIN}
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.${BASE_DOMAIN}/apps/user_oidc/code
webOrigins:
- https://nextcloud.${BASE_DOMAIN}
# 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.${BASE_DOMAIN}/*
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.${BASE_DOMAIN}/api/auth/oidc/keycloak/redirect
webOrigins:
- https://semaphore.${BASE_DOMAIN}
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.${BASE_DOMAIN}/oidc-callback
webOrigins:
- https://headlamp.${BASE_DOMAIN}
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.${BASE_DOMAIN}/oauth2/callback
webOrigins:
- https://crafty.${BASE_DOMAIN}
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.${BASE_DOMAIN}/oauth2/callback
webOrigins:
- https://pihole.${BASE_DOMAIN}
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-*/<app>-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.${BASE_DOMAIN}/oauth2/callback
webOrigins:
- https://jackett.${BASE_DOMAIN}
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.${BASE_DOMAIN}/oauth2/callback
webOrigins:
- https://qbittorrent.${BASE_DOMAIN}
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.${BASE_DOMAIN}/oauth2/callback
webOrigins:
- https://radarr.${BASE_DOMAIN}
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.${BASE_DOMAIN}/oauth2/callback
webOrigins:
- https://sonarr.${BASE_DOMAIN}
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) + PASSKEY login (2026-10-17). The realm browserFlow is
# bound to browser-mfa at the bottom of this file.
# ===========================================================================================
# Non-admins log in without OTP; platform-admins must enroll/present TOTP. 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 flow via the admin console.
#
# THE SHAPE, AND WHY IT IS THIS SHAPE
# -----------------------------------
# browser-mfa (top level, bound as browserFlow)
# auth-cookie ALTERNATIVE already-authenticated SSO cookie
# identity-provider-redirector ALTERNATIVE (no IdPs configured; kept for parity)
# browser-mfa-forms ALTERNATIVE the interactive path:
# auth-username-form REQUIRED WHO are you (no password yet)
# browser-mfa-passkey-or-password REQUIRED ...and PROVE it, one of two ways:
# webauthn-authenticator-passwordless ALTERNATIVE a passkey, or
# browser-mfa-password-and-otp ALTERNATIVE the password path:
# auth-password-form REQUIRED password, then
# browser-mfa-conditional-otp CONDITIONAL TOTP iff platform-admin
#
# WHY THE USERNAME/PASSWORD SCREEN HAD TO BE SPLIT IN TWO. The previous flow used the combined
# `auth-username-password-form`, which collects both on one page. A passkey branch cannot live
# behind that: the server must know WHICH user is logging in before it can decide whether a
# passkey is even available, and the combined form has already taken the password by the time
# it knows. So it is replaced by the `auth-username-form` / `auth-password-form` pair — the
# same decomposition Keycloak's own passwordless recipe uses. THIS IS A VISIBLE UX CHANGE FOR
# EVERY USER, passkey or not: login becomes two screens (username, then password) instead of
# one. That cost is accepted deliberately — it is the enabling change, it is what Google and
# Microsoft logins already do, and the alternative (below) is worse.
#
# WHY NOT USERNAMELESS ("just tap your passkey", no username screen at all). Keycloak 26.0.6
# does support it: WebAuthnPasswordlessAuthenticator.requiresUser() returns FALSE, so the
# authenticator can legally run as a first step with no user in context. We deliberately do not
# do that, because of how the flow engine then behaves. In DefaultAuthenticationFlow the
# "does this user actually have this credential?" guard sits INSIDE the requiresUser() branch;
# with requiresUser() false and no user yet set, the engine skips that guard and simply invokes
# the authenticator. A passkey execution placed before the username form is therefore offered
# to EVERYONE, so every login — including every password-only user and every family member who
# will never own a passkey — would open on a passkey prompt they must dismiss. That is the
# opposite of additive. Keeping the passkey step AFTER auth-username-form means a user is in
# context, which is what makes the graceful degradation below actually work.
#
# WHY THE PASSKEY BRANCH DEGRADES CLEANLY TO A PASSWORD (the property the whole "additive"
# claim rests on). With a user in context, the engine takes the non-requiresUser branch and
# raises CREDENTIAL_SETUP_REQUIRED when the user has no credential of this type and the
# authenticator's factory does not allow self-setup — and WebAuthnPasswordlessAuthenticatorFactory
# .isUserSetupAllowed() is false, and the authenticator is a CredentialValidator, so both
# conditions hold. That exception is raised INSIDE the ALTERNATIVE loop, which catches it,
# marks the execution ATTEMPTED and proceeds to the next alternative. Net effect: a user with
# no passkey never sees a passkey screen and lands on the password form; a user WITH one gets
# the passkey screen and keeps the password as a "Try another way" option. Note that for a
# user holding BOTH, which screen appears FIRST is decided by Keycloak's authentication
# selection list (the user's own credential ordering), not by the priorities below.
#
# WHY A PASSKEY LOGIN IS NOT ALSO ASKED FOR TOTP. browser-mfa-conditional-otp hangs off the
# PASSWORD branch only, so a platform-admin who authenticates with a passkey is not challenged
# for TOTP. That is intentional, and it rests on the policy above: userVerification is
# `required`, so the authenticator has already proved possession of hardware AND verified the
# human (biometric or PIN) in one phishing-resistant, origin-bound ceremony. Stacking TOTP on
# top would add a replayable, phishable shared secret to a credential that is neither, for
# friction on every login. If a future operator disagrees, the change is small and local: move
# the browser-mfa-conditional-otp reference out of browser-mfa-password-and-otp and into
# browser-mfa-passkey-or-password's parent as a REQUIRED step, so it applies to both branches.
#
# WHY THERE IS NO `requiredActions` BLOCK IN THIS FILE — DO NOT ADD ONE
# --------------------------------------------------------------------
# Enrolment uses Keycloak's `webauthn-register-passwordless` required action, and the obvious
# move is to declare it here. DO NOT. In Keycloak 26.0.6 the realm import treats
# requiredActions as REPLACE, NOT MERGE: DefaultExportImportManager.importRealm branches on
# `if (rep.getRequiredActions() != null)` and, when the representation declares ANY, imports
# exactly those plus delete_account — the `else` branch that installs the full default set
# (DefaultRequiredActions.addActions) never runs. RealmManager.setupRequiredActions is no
# backstop either: it fires only when the realm has ZERO required-action providers.
#
# So declaring one action would create this realm with that action and delete_account and
# NOTHING ELSE — no CONFIGURE_TOTP, no UPDATE_PASSWORD, no VERIFY_EMAIL, no delete_credential,
# no UPDATE_PROFILE. The first casualty would be the MFA requirement this very flow enforces:
# with CONFIGURE_TOTP unregistered, a platform-admin who does not yet have TOTP could never
# enrol one, while auth-otp-form below still demands it — a hard lock-out of every
# platform-admin from every SSO-gated app, including Headlamp and kubectl, delivered by a
# change whose stated purpose was to ADD a login method.
#
# And the declaration buys nothing, because the default set ALREADY CONTAINS IT.
# DefaultRequiredActions registers `webauthn-register-passwordless` with enabled=true,
# defaultAction=false, priority 80, gated on Profile.Feature.WEB_AUTHN — which is Type.DEFAULT
# in 26.0.6, i.e. on unless explicitly disabled. enabled=true / defaultAction=false IS the
# additive-and-optional posture this change wants: the action exists and can be triggered for
# a user on demand, but it is not forced on anyone at next login. Declaring it would restate
# the default while deleting ten siblings.
#
# (The symmetric question for authenticationFlows has the opposite answer, which is why THIS
# block is safe: importAuthenticationFlows adds the declared flows and then backfills each
# unbound binding — browser, registration, direct grant, reset credentials, client auth, first
# broker login, docker auth — from the built-in defaults individually. Declaring flows does not
# suppress the built-ins; declaring required actions does suppress them.)
authenticationFlows:
- alias: browser-mfa
description: "Browser flow: passkey or password, with conditional OTP 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
# Identify the user FIRST (username only), then prove it. Both steps are REQUIRED: the
# username is never optional, and neither is proving possession of the account.
- alias: browser-mfa-forms
description: "Username, then passkey or password (+ conditional OTP)."
providerId: basic-flow
topLevel: false
builtIn: false
authenticationExecutions:
# auth-username-form = Keycloak's "Username Form" (UsernameFormFactory) — resolves the
# user WITHOUT taking a password, which is the prerequisite for offering a passkey.
# Replaces the previous auth-username-password-form; see the header for the UX cost.
- authenticator: auth-username-form
authenticatorFlow: false
requirement: REQUIRED
priority: 10
userSetupAllowed: false
- authenticatorFlow: true
requirement: REQUIRED
priority: 20
flowAlias: browser-mfa-passkey-or-password
userSetupAllowed: false
# THE FORK. Two ALTERNATIVEs, so satisfying either one completes the flow. Priority puts the
# passkey first for users who have one; users who do not fall through to the password branch
# automatically (CREDENTIAL_SETUP_REQUIRED is caught by the ALTERNATIVE loop — see header).
- alias: browser-mfa-passkey-or-password
description: "A passkey (WebAuthn passwordless), or password with conditional OTP."
providerId: basic-flow
topLevel: false
builtIn: false
authenticationExecutions:
# webauthn-authenticator-passwordless = "WebAuthn Passwordless Authenticator"
# (WebAuthnPasswordlessAuthenticatorFactory). It reads the realm's PASSWORDLESS policy
# declared above — NOT the two-factor webAuthnPolicy* family. Keep it ALTERNATIVE: as
# REQUIRED it would force a passkey on every user, which is the lock-out this change is
# explicitly designed not to create.
- authenticator: webauthn-authenticator-passwordless
authenticatorFlow: false
requirement: ALTERNATIVE
priority: 10
userSetupAllowed: false
- authenticatorFlow: true
requirement: ALTERNATIVE
priority: 20
flowAlias: browser-mfa-password-and-otp
userSetupAllowed: false
# The unchanged pre-passkey behaviour, relocated intact into the password branch: password,
# then TOTP iff the user holds the platform-admin role. Nothing about the OTP condition
# changed; it simply no longer sits on the path a passkey login takes.
- alias: browser-mfa-password-and-otp
description: "Password, then conditional OTP for platform-admins."
providerId: basic-flow
topLevel: false
builtIn: false
authenticationExecutions:
# auth-password-form = Keycloak's "Password Form" (PasswordFormFactory) — the password
# half of the old combined form, now that the username has already been collected.
- authenticator: auth-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