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.
This commit is contained in:
parent
4832e5da5d
commit
32b5f147bc
9 changed files with 616 additions and 0 deletions
|
|
@ -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 }}"
|
||||
|
|
|
|||
|
|
@ -162,6 +162,9 @@ docker_compose_hosts:
|
|||
nextcloud_hosts:
|
||||
hosts:
|
||||
cloud:
|
||||
keycloak_auth_flow_hosts:
|
||||
hosts:
|
||||
keycloak:
|
||||
nginx_hosts:
|
||||
hosts:
|
||||
acmedns:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
62
resources/chaosknoten/keycloak/auth_flows/browser.yaml
Normal file
62
resources/chaosknoten/keycloak/auth_flows/browser.yaml
Normal 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
|
||||
114
roles/keycloak_auth_flow/README.md
Normal file
114
roles/keycloak_auth_flow/README.md
Normal 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
|
||||
```
|
||||
3
roles/keycloak_auth_flow/defaults/main.yaml
Normal file
3
roles/keycloak_auth_flow/defaults/main.yaml
Normal 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
|
||||
374
roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py
Normal file
374
roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py
Normal 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()
|
||||
24
roles/keycloak_auth_flow/meta/argument_specs.yaml
Normal file
24
roles/keycloak_auth_flow/meta/argument_specs.yaml
Normal 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
|
||||
25
roles/keycloak_auth_flow/tasks/main.yaml
Normal file
25
roles/keycloak_auth_flow/tasks/main.yaml
Normal 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 }}"
|
||||
Loading…
Reference in a new issue