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.
374 lines
17 KiB
Python
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()
|