--- # 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. (httproute.yaml, and the Keycloak CR's hostname), so the legal choices # are keycloak., , 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., 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//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= 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//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 (/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.${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-*/-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