From 18b01045e5d66501b949453fbbbc6f4a3bed6963 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Thu, 8 Oct 2026 11:06:00 +0200 Subject: [PATCH] Update to new manually created flow --- .../keycloak/auth_flows/browser.yaml | 75 ++++++------------- roles/keycloak_auth_flow/README.md | 13 +--- .../library/keycloak_auth_flow_lint.py | 34 ++------- 3 files changed, 31 insertions(+), 91 deletions(-) diff --git a/resources/chaosknoten/keycloak/auth_flows/browser.yaml b/resources/chaosknoten/keycloak/auth_flows/browser.yaml index 028bd70e..2c06f4f8 100644 --- a/resources/chaosknoten/keycloak/auth_flows/browser.yaml +++ b/resources/chaosknoten/keycloak/auth_flows/browser.yaml @@ -1,62 +1,31 @@ --- realm: ccchh -alias: browser passkey and token -description: passkey or (password and (TOTP or token)) +alias: passkey and 2FA +description: Login flow with passkey and proper 2FA handling. providerId: basic-flow authenticationExecutions: - - providerId: auth-cookie - requirement: ALTERNATIVE - - providerId: auth-spnego - requirement: DISABLED - - providerId: identity-provider-redirector - requirement: ALTERNATIVE - - subFlow: browser passkey and token browser passkey Organization - requirement: DISABLED + - subFlow: pk2fa-login + requirement: REQUIRED authenticationExecutions: - - subFlow: browser passkey and token browser passkey Browser - Conditional Organization - requirement: CONDITIONAL + - providerId: auth-cookie + requirement: ALTERNATIVE + - subFlow: pk2fa-authentication + requirement: ALTERNATIVE authenticationExecutions: - - providerId: conditional-user-configured + - providerId: auth-username-password-form requirement: REQUIRED - - providerId: organization - requirement: ALTERNATIVE - - subFlow: browser passkey and token browser passkey forms - requirement: ALTERNATIVE - authenticationExecutions: - - subFlow: browser passkey and token Passkey or Password - requirement: REQUIRED - authenticationExecutions: - - providerId: webauthn-authenticator-passwordless - requirement: ALTERNATIVE - - subFlow: browser passkey and token Password + OTP - requirement: ALTERNATIVE + - subFlow: pk2fa-conditional-2fa + requirement: CONDITIONAL authenticationExecutions: - - providerId: auth-username-password-form + - providerId: conditional-credential requirement: REQUIRED - - subFlow: browser passkey and token Conditional OTP - requirement: CONDITIONAL - authenticationExecutions: - - providerId: conditional-credential - requirement: REQUIRED - authenticationConfig: - alias: browser passkey and token not passkey - config: - credentials: webauthn-passwordless - included: 'false' - - providerId: conditional-user-configured - requirement: REQUIRED - - providerId: auth-otp-form - requirement: REQUIRED - - subFlow: WebAuthn Authenticator if configured for the user - requirement: CONDITIONAL - authenticationExecutions: - - providerId: conditional-credential - requirement: REQUIRED - authenticationConfig: - alias: webauthn-passwordless - config: - credentials: webauthn-passwordless - - providerId: conditional-user-configured - requirement: REQUIRED - - providerId: webauthn-authenticator - requirement: REQUIRED + authenticationConfig: + alias: pk2fa-conditional-2fa-passwordless-conditional + config: + credentials: webauthn-passwordless + - providerId: conditional-user-configured + requirement: REQUIRED + - providerId: webauthn-authenticator + requirement: ALTERNATIVE + - providerId: auth-otp-form + requirement: ALTERNATIVE diff --git a/roles/keycloak_auth_flow/README.md b/roles/keycloak_auth_flow/README.md index 6c271360..6ae18dda 100644 --- a/roles/keycloak_auth_flow/README.md +++ b/roles/keycloak_auth_flow/README.md @@ -57,13 +57,6 @@ checks each flow definition before it's ever sent to the server. sub-flow's own `requirement` is literally `CONDITIONAL`. See the Keycloak Server Admin Guide, ["Configuring authentication → Execution requirements"](https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/22.0/html/server_administration_guide/configuring-authentication_server_administration_guide): "Condition executions can only be contained in Conditional subflow." -- **OTP + WebAuthn siblings under one shared `conditional-user-configured`**: this hits - [keycloak/keycloak#29515](https://github.com/keycloak/keycloak/issues/29515) — users with - neither credential configured get a generic login error instead of the check being skipped - (also discussed in - [keycloak/keycloak#14988](https://github.com/keycloak/keycloak/discussions/14988)). The fix is - to give each credential type its own nested `CONDITIONAL` sub-flow with its own - `conditional-user-configured` check. - **`conditional-credential` config**: requires an `authenticationConfig` with a non-empty `credentials` entry, and `included` (if set) must be the string `"true"` or `"false"` — Keycloak stores authenticator config values as strings. See @@ -80,8 +73,8 @@ checks each flow definition before it's ever sent to the server. execution ended up under a `Conditional` sub-flow. - **`conditional-credential` `included` semantics reminder**: restates what the configured `included` value actually means (`true` → condition is true when *any* listed credential was - used; `false` → true when *none* were used). This is unlabeled in the admin console and easy to - get backwards — see the same + used; `false` or unset → true when *none* were used, since Keycloak parses a missing value as + `false`). This is unlabeled in the admin console and easy to get backwards — see the same [conditions.adoc](https://github.com/keycloak/keycloak/blob/main/docs/documentation/server_admin/topics/authentication/conditions.adoc) reference as above. - **`auth-conditional-otp-form` with no config**: unlike the plain `auth-otp-form`, `Conditional @@ -101,7 +94,7 @@ checks each flow definition before it's ever sent to the server. ```yaml keycloak_auth_flow__flows: - realm: ccchh - alias: browser passkey and token + alias: passkey and 2FA providerId: basic-flow authenticationExecutions: - providerId: auth-cookie diff --git a/roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py b/roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py index 032b98d7..ff22cb79 100644 --- a/roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py +++ b/roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py @@ -128,12 +128,6 @@ ALLOWED_NODE_KEYS = { } MAX_SUBFLOW_DEPTH = 10 -# providerIds relevant to the sibling-condition bug tracked as keycloak/keycloak#29515: mixing -# these two under one shared 'conditional-user-configured' check breaks login for users with -# neither credential configured. -OTP_PROVIDER_IDS = {"auth-otp-form", "auth-conditional-otp-form"} -WEBAUTHN_PROVIDER_IDS = {"webauthn-authenticator", "webauthn-authenticator-passwordless"} - class FlowValidationError(Exception): pass @@ -165,8 +159,9 @@ def validate_authentication_config(auth_config, path, provider_id, warnings): f"(got {included!r}) — Keycloak stores authenticator config values as strings" ) # The included=true/false semantics are unlabeled in the Keycloak admin console and easy to - # get backwards, so restate them every time as a standing reminder. - if included == "false": + # get backwards, so restate them every time as a standing reminder. Keycloak reads the + # value with Boolean.parseBoolean(), so an unset 'included' behaves like "false". + if included != "true": meaning = f"TRUE when NONE of [{credentials}] were used (inverted)" else: meaning = f"TRUE when ANY of [{credentials}] was used" @@ -231,9 +226,9 @@ def validate_node(node, path, depth, seen_subflow_names, seen_provider_ids, warn if not isinstance(children, list): raise FlowValidationError(f"{path}.authenticationExecutions: must be a list") - # Direct-child providerIds, used by the two checks below. Only *direct* children count: - # Keycloak's "Condition executions can only be contained in Conditional subflow" rule, and - # the sibling-condition bug, both concern immediate siblings, not deeper descendants. + # Direct-child providerIds, used by the check below. Only *direct* children count: + # Keycloak's "Condition executions can only be contained in Conditional subflow" rule + # concerns immediate children, not deeper descendants. direct_provider_ids = { child.get("providerId") for child in children @@ -252,23 +247,6 @@ def validate_node(node, path, depth, seen_subflow_names, seen_provider_ids, warn "never fires)" ) - # Gotcha: keycloak/keycloak#29515 — an OTP provider and a WebAuthn provider as siblings - # under one shared 'conditional-user-configured' check breaks login for users with neither - # credential configured. Give each credential type its own nested Conditional subflow with - # its own 'conditional-user-configured' check instead. - if ( - "conditional-user-configured" in direct_provider_ids - and direct_provider_ids & OTP_PROVIDER_IDS - and direct_provider_ids & WEBAUTHN_PROVIDER_IDS - ): - raise FlowValidationError( - f"{path}: sub-flow {name!r} mixes an OTP provider and a WebAuthn provider as " - "siblings under one shared 'conditional-user-configured' check — this hits " - "keycloak/keycloak#29515: users with neither credential configured get a generic " - "login error instead of the check being skipped. Give each credential type its own " - "nested Conditional subflow with its own 'conditional-user-configured' check." - ) - for index, child in enumerate(children): validate_node( child,