ansible-infra/roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py
Stefan Bethke 32b5f147bc
All checks were successful
/ build (pull_request) Successful in 47s
/ Ansible Lint (push) Successful in 5m31s
/ Ansible Lint (pull_request) Successful in 4m35s
Manage the CCCHH browser authentication flow as YAML
Adds a keycloak_auth_flow role that locally validates flow
definitions (structure, nesting depth, known provider IDs, and a set
of Keycloak authentication-flow gotchas: conditions silently ignored
outside a Conditional subflow, keycloak/keycloak#29515's OTP+WebAuthn
sibling bug, conditional-credential config shape/semantics, and more)
before deploying them idempotently via
middleware_automation.keycloak.keycloak_authentication_v2.

Includes the CCCHH realm's actual "browser passkey and token" flow
(the realm's real browserFlow binding, not the untouched built-in
"browser" flow), exported from the live server so it's now reviewable
and redeployable from this repo instead of only editable in the
Keycloak admin console.
2026-09-02 11:07:45 +02:00

374 lines
17 KiB
Python

#!/usr/bin/python
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_auth_flow_lint
short_description: Locally validate a Keycloak authentication flow definition
description:
- Statically validates the structure of a nested Keycloak authentication flow definition, in
the shape accepted by C(middleware_automation.keycloak.keycloak_authentication_v2)'s
O(middleware_automation.keycloak.keycloak_authentication_v2#module:authenticationExecutions)
option.
- This performs no network calls and requires no Keycloak credentials, so it can catch YAML
authoring mistakes (typos, missing keys, bad nesting) before ever touching a live server.
- Some checks (for example whether a C(providerId) is spelled correctly) are necessarily best
effort, since Keycloak's set of registered authenticator providers depends on the server and
its installed SPI plugins. Those only produce warnings, not failures.
options:
realm:
description: The name of the realm the flow belongs to.
required: true
type: str
alias:
description: The name of the authentication flow.
required: true
type: str
providerId:
description: The C(providerId) for the flow.
choices: [basic-flow, client-flow]
type: str
default: basic-flow
authenticationExecutions:
description: The desired execution configuration for the flow, at root level.
required: true
type: list
elements: dict
author:
- CCCHH
"""
EXAMPLES = r"""
- name: Validate a flow definition before deploying it
keycloak_auth_flow_lint:
realm: "{{ item.realm }}"
alias: "{{ item.alias }}"
providerId: "{{ item.providerId | default('basic-flow') }}"
authenticationExecutions: "{{ item.authenticationExecutions }}"
loop: "{{ keycloak_auth_flow__flows }}"
"""
RETURN = r"""
warnings:
description: Non-fatal issues found in the flow definition.
returned: always
type: list
elements: str
"""
from ansible.module_utils.basic import AnsibleModule
# Non-exhaustive list of Keycloak built-in authenticator provider IDs relevant to browser flows.
# An unrecognized providerId only produces a warning, not a failure, since custom SPI providers
# exist. This list is NOT an authoritative source, just a fast offline pre-check:
# - Most entries were observed directly on the live CCCHH Keycloak server (26.x): the built-in
# flows (browser, direct grant, registration, reset credentials, clients, docker auth, first
# broker login) and the custom "browser passkey and token" flow, fetched via the admin API.
# - The rest (other idp-*, registration-*, conditional-*, auth-conditional-otp-form,
# auth-password-form, identity-provider-autolink) are from general Keycloak knowledge, not
# verified against this server.
# To check or extend this list later:
# - Live server (authoritative, and what keycloak_authentication_v2 itself checks against at
# deploy time): GET /admin/realms/{realm}/authentication/authenticator-providers
# - Keycloak source: each provider's getId() in its *AuthenticatorFactory.java, under
# https://github.com/keycloak/keycloak/tree/main/services/src/main/java/org/keycloak/authentication/authenticators
KNOWN_PROVIDER_IDS = {
"auth-conditional-otp-form",
"auth-cookie",
"auth-otp-form",
"auth-password-form",
"auth-spnego",
"auth-username-password-form",
"client-jwt",
"client-secret",
"client-secret-jwt",
"client-x509",
"conditional-credential",
"conditional-level-of-authentication",
"conditional-user-attribute",
"conditional-user-configured",
"conditional-user-role",
"direct-grant-validate-otp",
"direct-grant-validate-password",
"direct-grant-validate-username",
"docker-http-basic-authenticator",
"identity-provider-autolink",
"identity-provider-redirector",
"idp-confirm-link",
"idp-create-user-if-unique",
"idp-email-verification",
"idp-review-profile",
"idp-username-password-form",
"organization",
"registration-page-form",
"registration-password-action",
"registration-recaptcha-action",
"registration-terms-and-conditions",
"registration-user-creation",
"reset-credential-email",
"reset-credentials-choose-user",
"reset-password",
"webauthn-authenticator",
"webauthn-authenticator-passwordless",
}
REQUIREMENTS = {"REQUIRED", "ALTERNATIVE", "DISABLED", "CONDITIONAL"}
SUBFLOW_TYPES = {"basic-flow", "form-flow"}
ALLOWED_NODE_KEYS = {
"requirement",
"providerId",
"subFlow",
"subFlowType",
"authenticationExecutions",
"authenticationConfig",
}
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
def validate_authentication_config(auth_config, path, provider_id, warnings):
if not isinstance(auth_config, dict):
raise FlowValidationError(f"{path}.authenticationConfig: must be a mapping")
extra = set(auth_config) - {"alias", "config"}
if extra:
raise FlowValidationError(f"{path}.authenticationConfig: unexpected key(s): {', '.join(sorted(extra))}")
if not isinstance(auth_config.get("alias"), str) or not auth_config["alias"]:
raise FlowValidationError(f"{path}.authenticationConfig: 'alias' is required and must be a non-empty string")
config = auth_config.get("config")
if not isinstance(config, dict):
raise FlowValidationError(f"{path}.authenticationConfig: 'config' is required and must be a mapping")
if provider_id == "conditional-credential":
credentials = config.get("credentials")
if not credentials:
raise FlowValidationError(
f"{path}.authenticationConfig.config: 'conditional-credential' requires a non-empty "
"'credentials' entry"
)
included = config.get("included")
if included is not None and included not in ("true", "false"):
raise FlowValidationError(
f"{path}.authenticationConfig.config: 'included' must be the string 'true' or 'false' "
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":
meaning = f"TRUE when NONE of [{credentials}] were used (inverted)"
else:
meaning = f"TRUE when ANY of [{credentials}] was used"
warnings.append(
f"{path}: 'conditional-credential' with included={included!r} — condition is {meaning}. "
"This is easy to get backwards since the admin console has no tooltip for it."
)
elif provider_id == "auth-conditional-otp-form" and not config:
warnings.append(
f"{path}: 'auth-conditional-otp-form' (Conditional OTP Form) has an empty config — "
"unlike the plain 'auth-otp-form', its skip/force logic is entirely self-contained (by "
"user attribute, role, header, or default) and does NOT inherit a wrapping Condition-* "
"check; with no config it defaults to always showing the OTP form."
)
def validate_node(node, path, depth, seen_subflow_names, seen_provider_ids, warnings):
if not isinstance(node, dict):
raise FlowValidationError(f"{path}: must be a mapping")
extra = set(node) - ALLOWED_NODE_KEYS
if extra:
raise FlowValidationError(f"{path}: unexpected key(s): {', '.join(sorted(extra))}")
requirement = node.get("requirement")
if requirement not in REQUIREMENTS:
raise FlowValidationError(
f"{path}.requirement: must be one of {sorted(REQUIREMENTS)}, got {requirement!r}"
)
has_provider = node.get("providerId") is not None
has_subflow = node.get("subFlow") is not None
if has_provider and has_subflow:
raise FlowValidationError(f"{path}: has both 'providerId' and 'subFlow' — an execution is exactly one of the two")
if not has_provider and not has_subflow:
raise FlowValidationError(f"{path}: must have exactly one of 'providerId' or 'subFlow'")
if has_subflow:
if depth >= MAX_SUBFLOW_DEPTH:
raise FlowValidationError(
f"{path}: nesting exceeds the maximum of {MAX_SUBFLOW_DEPTH} sub-flow levels "
"supported by middleware_automation.keycloak.keycloak_authentication_v2"
)
name = node["subFlow"]
if not isinstance(name, str) or not name:
raise FlowValidationError(f"{path}.subFlow: must be a non-empty string")
if name in seen_subflow_names:
raise FlowValidationError(f"{path}.subFlow: duplicate sub-flow name {name!r} within this flow")
seen_subflow_names.add(name)
subflow_type = node.get("subFlowType", "basic-flow")
if subflow_type not in SUBFLOW_TYPES:
raise FlowValidationError(f"{path}.subFlowType: must be one of {sorted(SUBFLOW_TYPES)}, got {subflow_type!r}")
if "authenticationConfig" in node:
raise FlowValidationError(f"{path}: 'authenticationConfig' is not valid on a sub-flow node")
children = node.get("authenticationExecutions")
if not children:
raise FlowValidationError(f"{path}.authenticationExecutions: sub-flow {name!r} must define at least one execution")
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_provider_ids = {
child.get("providerId")
for child in children
if isinstance(child, dict) and child.get("providerId")
}
# Gotcha: a 'conditional-*' execution is silently ignored (no error, condition just never
# fires) unless the wrapping subflow's own requirement is literally CONDITIONAL.
conditional_children = {pid for pid in direct_provider_ids if pid.startswith("conditional-")}
if conditional_children and requirement != "CONDITIONAL":
raise FlowValidationError(
f"{path}: sub-flow {name!r} contains condition execution(s) "
f"({', '.join(sorted(conditional_children))}) but its own requirement is "
f"{requirement!r}, not CONDITIONAL — Keycloak silently ignores condition executions "
"whose wrapping subflow isn't CONDITIONAL (no error is shown; the condition just "
"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,
f"{path}.authenticationExecutions[{index}]",
depth + 1,
seen_subflow_names,
seen_provider_ids,
warnings,
)
else:
if "subFlowType" in node:
raise FlowValidationError(f"{path}: 'subFlowType' is not valid on an execution node")
if "authenticationExecutions" in node:
raise FlowValidationError(f"{path}: 'authenticationExecutions' is not valid on an execution node")
provider_id = node["providerId"]
if not isinstance(provider_id, str) or not provider_id:
raise FlowValidationError(f"{path}.providerId: must be a non-empty string")
seen_provider_ids.add(provider_id)
if provider_id not in KNOWN_PROVIDER_IDS:
warnings.append(
f"{path}: providerId '{provider_id}' is not in the list of known Keycloak provider "
"IDs — double-check for a typo, or ignore if this is a custom SPI provider"
)
if requirement == "CONDITIONAL" and not provider_id.startswith("conditional-"):
warnings.append(
f"{path}: requirement is CONDITIONAL but providerId '{provider_id}' does not look "
"like a condition provider (expected something like 'conditional-*')"
)
auth_config = node.get("authenticationConfig")
if auth_config is not None:
validate_authentication_config(auth_config, path, provider_id, warnings)
elif provider_id == "conditional-credential":
raise FlowValidationError(
f"{path}: 'conditional-credential' requires an authenticationConfig (with a "
"'credentials' entry) — without it the condition doesn't check anything"
)
elif provider_id == "auth-conditional-otp-form":
warnings.append(
f"{path}: 'auth-conditional-otp-form' (Conditional OTP Form) has no "
"authenticationConfig — unlike the plain 'auth-otp-form', its skip/force logic is "
"entirely self-contained (by user attribute, role, header, or default) and does NOT "
"inherit a wrapping Condition-* check; with no config it defaults to always showing "
"the OTP form."
)
def main() -> None:
module = AnsibleModule(
argument_spec=dict(
realm=dict(type="str", required=True),
alias=dict(type="str", required=True),
providerId=dict(type="str", default="basic-flow", choices=["basic-flow", "client-flow"]),
authenticationExecutions=dict(type="list", elements="dict", required=True),
),
supports_check_mode=True,
)
warnings: list[str] = []
seen_subflow_names: set[str] = set()
seen_provider_ids: set[str] = set()
try:
for index, execution in enumerate(module.params["authenticationExecutions"]):
validate_node(
execution,
f"authenticationExecutions[{index}]",
depth=0,
seen_subflow_names=seen_subflow_names,
seen_provider_ids=seen_provider_ids,
warnings=warnings,
)
except FlowValidationError as e:
module.fail_json(
msg=f"{module.params['realm']}/{module.params['alias']}: {e}",
warnings=warnings,
)
# Gotcha: with native passkey support, 'auth-username-password-form' can itself complete a
# full passkey login, so a separately-added 'webauthn-authenticator-passwordless' execution
# elsewhere in the flow may end up dead/unreached with no indication in the admin console.
if "auth-username-password-form" in seen_provider_ids and "webauthn-authenticator-passwordless" in seen_provider_ids:
warnings.append(
"flow has both 'auth-username-password-form' and 'webauthn-authenticator-passwordless' "
"— with native passkey support, the username/password form can itself complete a "
"passkey login, so it may be ambiguous (and unverifiable from the admin console) which "
"execution actually authenticates a given passkey login. Verify via the 'execution=<id>' "
"query parameter during a live test, or DEBUG logging on 'org.keycloak.authentication'."
)
for warning in warnings:
module.warn(f"{module.params['realm']}/{module.params['alias']}: {warning}")
module.exit_json(
changed=False,
msg=f"{module.params['realm']}/{module.params['alias']}: flow definition is structurally valid",
warnings=warnings,
)
if __name__ == "__main__":
main()