diff --git a/inventories/chaosknoten/host_vars/keycloak.yaml b/inventories/chaosknoten/host_vars/keycloak.yaml index c288da52..5676172e 100644 --- a/inventories/chaosknoten/host_vars/keycloak.yaml +++ b/inventories/chaosknoten/host_vars/keycloak.yaml @@ -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 }}" diff --git a/inventories/chaosknoten/hosts.yaml b/inventories/chaosknoten/hosts.yaml index 0c841fb6..494c7ace 100644 --- a/inventories/chaosknoten/hosts.yaml +++ b/inventories/chaosknoten/hosts.yaml @@ -162,6 +162,9 @@ docker_compose_hosts: nextcloud_hosts: hosts: cloud: +keycloak_auth_flow_hosts: + hosts: + keycloak: nginx_hosts: hosts: acmedns: diff --git a/playbooks/deploy.yaml b/playbooks/deploy.yaml index 2a1498ac..10c7fcd9 100644 --- a/playbooks/deploy.yaml +++ b/playbooks/deploy.yaml @@ -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: diff --git a/resources/chaosknoten/keycloak/auth_flows/browser.yaml b/resources/chaosknoten/keycloak/auth_flows/browser.yaml new file mode 100644 index 00000000..028bd70e --- /dev/null +++ b/resources/chaosknoten/keycloak/auth_flows/browser.yaml @@ -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 diff --git a/roles/keycloak_auth_flow/README.md b/roles/keycloak_auth_flow/README.md new file mode 100644 index 00000000..6c271360 --- /dev/null +++ b/roles/keycloak_auth_flow/README.md @@ -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=` 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 +``` diff --git a/roles/keycloak_auth_flow/defaults/main.yaml b/roles/keycloak_auth_flow/defaults/main.yaml new file mode 100644 index 00000000..c2fa8746 --- /dev/null +++ b/roles/keycloak_auth_flow/defaults/main.yaml @@ -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 diff --git a/roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py b/roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py new file mode 100644 index 00000000..032b98d7 --- /dev/null +++ b/roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py @@ -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=' " + "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() diff --git a/roles/keycloak_auth_flow/meta/argument_specs.yaml b/roles/keycloak_auth_flow/meta/argument_specs.yaml new file mode 100644 index 00000000..badee9b7 --- /dev/null +++ b/roles/keycloak_auth_flow/meta/argument_specs.yaml @@ -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 diff --git a/roles/keycloak_auth_flow/tasks/main.yaml b/roles/keycloak_auth_flow/tasks/main.yaml new file mode 100644 index 00000000..1dfc169b --- /dev/null +++ b/roles/keycloak_auth_flow/tasks/main.yaml @@ -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 }}"