Manage the CCCHH browser authentication flow as YAML
All checks were successful
/ build (pull_request) Successful in 47s
/ Ansible Lint (push) Successful in 5m31s
/ Ansible Lint (pull_request) Successful in 4m35s

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.
This commit is contained in:
Stefan Bethke 2026-09-02 11:07:45 +02:00
commit 32b5f147bc
9 changed files with 616 additions and 0 deletions

View file

@ -17,3 +17,7 @@ nginx__configurations:
content: "{{ lookup('ansible.builtin.file', 'resources/chaosknoten/keycloak/nginx/keycloak-admin.hamburg.ccc.de.conf') }}"
- name: invite.hamburg.ccc.de
content: "{{ lookup('ansible.builtin.file', 'resources/chaosknoten/keycloak/nginx/invite.hamburg.ccc.de.conf') }}"
keycloak_auth_flow__admin_password: "{{ secret__keycloak_admin_password }}"
keycloak_auth_flow__flows:
- "{{ lookup('ansible.builtin.file', 'resources/chaosknoten/keycloak/auth_flows/browser.yaml') | from_yaml }}"

View file

@ -162,6 +162,9 @@ docker_compose_hosts:
nextcloud_hosts:
hosts:
cloud:
keycloak_auth_flow_hosts:
hosts:
keycloak:
nginx_hosts:
hosts:
acmedns:

View file

@ -110,6 +110,13 @@
tags:
- docker_compose
- name: Ensure Keycloak authentication flows are configured
hosts: keycloak_auth_flow_hosts
roles:
- keycloak_auth_flow
tags:
- keycloak_auth_flow
- name: Ensure NGINX deployment on nginx_hosts
hosts: nginx_hosts:!public_reverse_proxy_hosts
roles:

View file

@ -0,0 +1,62 @@
---
realm: ccchh
alias: browser passkey and token
description: passkey or (password and (TOTP or token))
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
authenticationExecutions:
- subFlow: browser passkey and token browser passkey Browser - Conditional Organization
requirement: CONDITIONAL
authenticationExecutions:
- providerId: conditional-user-configured
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
authenticationExecutions:
- providerId: auth-username-password-form
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

View file

@ -0,0 +1,114 @@
# `keycloak_auth_flow` role
Manages Keycloak authentication flows (for example a realm's `browser` flow) as YAML checked
into this repo, instead of hand-editing them in the Keycloak admin console.
Each flow is validated locally (structure, nesting depth, known provider IDs) with the custom
[`keycloak_auth_flow_lint`](library/keycloak_auth_flow_lint.py) module before being deployed with
`middleware_automation.keycloak.keycloak_authentication_v2`, which applies changes idempotently
via a "safe swap" so the realm is never left without a working flow mid-update.
Both tasks run against `keycloak_auth_flow__admin_url`, which defaults to the local
`http://127.0.0.1:8080` — i.e. this role is meant to run on the Keycloak host itself, talking to
the Keycloak container directly, since the public admin hostname is IP-restricted.
## Required Arguments
- `keycloak_auth_flow__admin_password`: Password of `keycloak_auth_flow__admin_username` (an
admin in the `master` realm).
## Optional Arguments
- `keycloak_auth_flow__flows`: List of flow definitions to deploy. Defaults to `[]`. Each item:
- `realm`: Realm the flow belongs to.
- `alias`: Name of the flow.
- `providerId`: `basic-flow` (default) or `client-flow`.
- `description`: Optional human-readable description.
- `authenticationExecutions`: Nested list describing the flow's steps, in the shape documented
by `middleware_automation.keycloak.keycloak_authentication_v2` — each item is either an
execution (`providerId`, `requirement`, optionally `authenticationConfig`) or a sub-flow
(`subFlow`, `requirement`, optionally `subFlowType`, and a nested `authenticationExecutions`).
Up to 10 levels of sub-flow nesting are supported.
- `keycloak_auth_flow__admin_url`: Defaults to `http://127.0.0.1:8080`.
- `keycloak_auth_flow__admin_username`: Defaults to `admin`.
## Validation checks (`keycloak_auth_flow_lint`)
The lint module runs entirely offline (no Keycloak credentials or network access needed) and
checks each flow definition before it's ever sent to the server.
### Errors (fail the run)
- **Node shape**: every entry in `authenticationExecutions` must be a mapping with no unknown
keys, exactly one of `providerId` (an execution) or `subFlow` (a sub-flow), and a `requirement`
that is one of `REQUIRED`/`ALTERNATIVE`/`DISABLED`/`CONDITIONAL`. A sub-flow's `subFlowType`
(when given) must be `basic-flow` or `form-flow`, its name must be a non-empty string unique
within the flow, and it must define at least one child execution. These mirror the shape
`middleware_automation.keycloak.keycloak_authentication_v2` itself expects — see its
[module source](https://github.com/ansible-middleware/keycloak/blob/3.0.11/plugins/modules/keycloak_authentication_v2.py).
- **Nesting depth**: at most 10 levels of sub-flow nesting, matching the limit built into
`keycloak_authentication_v2` (see the module source linked above; the depth was raised from 4
to 10 in [ansible-middleware/keycloak#376](https://github.com/ansible-middleware/keycloak/pull/376)).
- **`authenticationConfig` shape**: when present, must be a mapping with only `alias` (non-empty
string) and `config` (a mapping) — again mirrors what
`keycloak_authentication_v2` accepts.
- **Condition execution outside a `CONDITIONAL` sub-flow**: a `conditional-*` execution is
silently ignored — no error, the condition just never fires — unless its immediate parent
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
[`ConditionalCredentialAuthenticatorFactory` javadoc](https://www.keycloak.org/docs-api/latest/javadocs/org/keycloak/authentication/authenticators/conditional/ConditionalCredentialAuthenticatorFactory.html)
and [conditions.adoc](https://github.com/keycloak/keycloak/blob/main/docs/documentation/server_admin/topics/authentication/conditions.adoc).
### Warnings (non-fatal)
- **Unrecognized `providerId`**: not in the module's built-in list of known Keycloak provider
IDs — could be a typo, or a legitimate custom SPI provider. See the constant
`KNOWN_PROVIDER_IDS` in the module source for how that list was compiled and how to check or
extend it (live server query vs. Keycloak's authenticator factory sources).
- **`CONDITIONAL` requirement on a non-`conditional-*` provider**: usually a sign the wrong
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
[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
OTP Form`'s 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. See
[`ConditionalOtpFormAuthenticator.java`](https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/authenticators/browser/ConditionalOtpFormAuthenticator.java).
- **Ambiguous passkey execution**: when both `auth-username-password-form` and
`webauthn-authenticator-passwordless` appear anywhere in the flow. With native passkey support,
the username/password form can itself complete a full passkey login, so it can be unclear —
and unverifiable from the admin console — which execution actually authenticated a given
login. No online source for this one; verify via the `execution=<id>` query parameter during a
live test, or `DEBUG` logging on `org.keycloak.authentication`.
## Example
```yaml
keycloak_auth_flow__flows:
- realm: ccchh
alias: browser passkey and token
providerId: basic-flow
authenticationExecutions:
- providerId: auth-cookie
requirement: ALTERNATIVE
- subFlow: forms
requirement: ALTERNATIVE
authenticationExecutions:
- providerId: auth-username-password-form
requirement: REQUIRED
```

View file

@ -0,0 +1,3 @@
keycloak_auth_flow__flows: [ ]
keycloak_auth_flow__admin_url: "http://127.0.0.1:8080"
keycloak_auth_flow__admin_username: admin

View file

@ -0,0 +1,374 @@
#!/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()

View file

@ -0,0 +1,24 @@
---
argument_specs:
main:
options:
keycloak_auth_flow__flows:
description: >-
List of authentication flow definitions to validate and deploy. Each item needs
C(realm), C(alias) and C(authenticationExecutions) (nested tree, see
middleware_automation.keycloak.keycloak_authentication_v2), and may set C(providerId)
and C(description).
type: list
elements: dict
keycloak_auth_flow__admin_url:
description: Base URL of the Keycloak Admin REST API.
type: str
default: "http://127.0.0.1:8080"
keycloak_auth_flow__admin_username:
description: Username of a Keycloak admin (in the C(master) realm) used to manage flows.
type: str
default: admin
keycloak_auth_flow__admin_password:
description: Password for O(keycloak_auth_flow__admin_username).
type: str
required: true

View file

@ -0,0 +1,25 @@
- name: Validate Keycloak authentication flow definitions
keycloak_auth_flow_lint:
realm: "{{ item.realm }}"
alias: "{{ item.alias }}"
providerId: "{{ item.providerId | default('basic-flow') }}"
authenticationExecutions: "{{ item.authenticationExecutions }}"
loop: "{{ keycloak_auth_flow__flows }}"
loop_control:
label: "{{ item.realm }}/{{ item.alias }}"
- name: Deploy Keycloak authentication flows
middleware_automation.keycloak.keycloak_authentication_v2:
auth_keycloak_url: "{{ keycloak_auth_flow__admin_url }}"
auth_realm: master
auth_username: "{{ keycloak_auth_flow__admin_username }}"
auth_password: "{{ keycloak_auth_flow__admin_password }}"
realm: "{{ item.realm }}"
alias: "{{ item.alias }}"
description: "{{ item.description | default(omit) }}"
providerId: "{{ item.providerId | default('basic-flow') }}"
authenticationExecutions: "{{ item.authenticationExecutions }}"
state: present
loop: "{{ keycloak_auth_flow__flows }}"
loop_control:
label: "{{ item.realm }}/{{ item.alias }}"