1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2026-03-22 05:09:12 +00:00

keycloak_realm_key: add full support for all Keycloak key providers (#11468)

* feat(keycloak_realm_key): add support for auto-generated key providers

Add support for Keycloak's auto-generated key providers where Keycloak
manages the key material automatically:

- rsa-generated: Auto-generates RSA signing keys
- hmac-generated: Auto-generates HMAC signing keys
- aes-generated: Auto-generates AES encryption keys
- ecdsa-generated: Auto-generates ECDSA signing keys

New algorithms:
- HMAC: HS256, HS384, HS512
- ECDSA: ES256, ES384, ES512
- AES: AES (no algorithm parameter needed)

New config options:
- secret_size: For HMAC/AES providers (key size in bytes)
- key_size: For RSA-generated provider (key size in bits)
- elliptic_curve: For ECDSA-generated provider (P-256, P-384, P-521)

Changes:
- Make private_key/certificate optional (only required for rsa/rsa-enc)
- Add provider-algorithm validation with clear error messages
- Fix KeyError when managing default realm keys (issue #11459)
- Maintain backward compatibility: RS256 default works for rsa/rsa-generated

Fixes: #11459

* fix: address sanity test failures

- Add 'default: RS256' to algorithm documentation to match spec
- Add no_log=True to secret_size parameter per sanity check

* feat(keycloak_realm_key): extend support for all Keycloak key providers

Add support for remaining auto-generated key providers:
- rsa-enc-generated (RSA encryption keys with RSA1_5, RSA-OAEP, RSA-OAEP-256)
- ecdh-generated (ECDH key exchange with ECDH_ES, ECDH_ES_A128KW/A192KW/A256KW)
- eddsa-generated (EdDSA signing with Ed25519, Ed448 curves)

Changes:
- Add provider-specific elliptic curve config key mapping
  (ecdsaEllipticCurveKey, ecdhEllipticCurveKey, eddsaEllipticCurveKey)
- Add PROVIDERS_WITHOUT_ALGORITHM constant for providers that don't need algorithm
- Add elliptic curve validation per provider type
- Update documentation with all supported algorithms and examples
- Add comprehensive integration tests for all new providers

This completes full coverage of all Keycloak key provider types.

* style: apply ruff formatting

* feat(keycloak_realm_key): add java-keystore provider and update_password

Add support for java-keystore provider to import keys from Java
Keystore (JKS or PKCS12) files on the Keycloak server filesystem.

Add update_password parameter to control password handling for
java-keystore provider:
- always (default): Always send passwords to Keycloak
- on_create: Only send passwords when creating, preserve existing
  passwords when updating (enables idempotent playbooks)

The on_create mode sends the masked value ("**********") that Keycloak
recognizes as "preserve existing password", matching the behavior when
re-importing an exported realm.

Replace password_checksum with update_password - the checksum approach
was complex and error-prone. The update_password parameter is simpler
and follows the pattern used by ansible.builtin.user module.

Also adds key_info return value containing kid, certificate fingerprint,
status, and expiration for java-keystore keys.

* address PR review feedback

- Remove no_log=True from secret_size (just an int, not sensitive)
- Add version_added: 12.4.0 to new parameters and return values
- Remove "Added in community.general 12.4.0" from description text
- Consolidate changelog entries into 4 focused entries
- Remove bugfix from changelog (now in separate PR #11470)

* address review feedback from russoz and felixfontein

- remove docstrings from module-local helpers
- remove line-by-line comments and unnecessary null guard
- use specific exceptions instead of bare except Exception
- use module.params["key"] instead of .get("key")
- consolidate changelog into single entry
- avoid "complete set" claim, reference Keycloak 26 instead

* address round 2 review feedback

- Extract remove_sensitive_config_keys() helper (DRY refactor)
- Simplify RS256 validation to single code path
- Add TypeError to inner except in compute_certificate_fingerprint()
- Remove redundant comments (L812, L1031)
- Switch .get() to direct dict access for module.params
This commit is contained in:
Ivan Kokalovic 2026-02-18 07:48:37 +01:00 committed by GitHub
parent 5e0fd1201c
commit 80d21f2a0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1615 additions and 39 deletions

View file

@ -64,7 +64,23 @@ options:
description:
- The name of the "provider ID" for the key.
- The value V(rsa-enc) has been added in community.general 8.2.0.
choices: ['rsa', 'rsa-enc']
- The value V(java-keystore) has been added in community.general 12.4.0. This provider imports keys from
a Java Keystore (JKS or PKCS12) file located on the Keycloak server filesystem.
- The values V(rsa-generated), V(hmac-generated), V(aes-generated), and V(ecdsa-generated) have been added in
community.general 12.4.0. These are auto-generated key providers where Keycloak manages the key material.
- The values V(rsa-enc-generated), V(ecdh-generated), and V(eddsa-generated) have been added in
community.general 12.4.0. These correspond to the auto-generated key providers available in Keycloak 26.
choices:
- rsa
- rsa-enc
- java-keystore
- rsa-generated
- rsa-enc-generated
- hmac-generated
- aes-generated
- ecdsa-generated
- ecdh-generated
- eddsa-generated
default: 'rsa'
type: str
config:
@ -94,15 +110,48 @@ options:
- Key algorithm.
- The values V(RS384), V(RS512), V(PS256), V(PS384), V(PS512), V(RSA1_5), V(RSA-OAEP), V(RSA-OAEP-256) have been
added in community.general 8.2.0.
- The values V(HS256), V(HS384), V(HS512) (for HMAC), V(ES256), V(ES384), V(ES512) (for ECDSA), and V(AES)
have been added in community.general 12.4.0.
- The values V(ECDH_ES), V(ECDH_ES_A128KW), V(ECDH_ES_A192KW), V(ECDH_ES_A256KW) (for ECDH key exchange),
and V(Ed25519), V(Ed448) (for EdDSA signing) have been added in community.general 12.4.0.
- For O(provider_id=rsa), O(provider_id=rsa-generated), and O(provider_id=java-keystore), defaults to V(RS256).
- For O(provider_id=rsa-enc) and O(provider_id=rsa-enc-generated), must be one of V(RSA1_5), V(RSA-OAEP), V(RSA-OAEP-256) (required, no default).
- For O(provider_id=hmac-generated), must be one of V(HS256), V(HS384), V(HS512) (required, no default).
- For O(provider_id=ecdsa-generated), must be one of V(ES256), V(ES384), V(ES512) (required, no default).
- For O(provider_id=ecdh-generated), must be one of V(ECDH_ES), V(ECDH_ES_A128KW), V(ECDH_ES_A192KW), V(ECDH_ES_A256KW) (required, no default).
- For O(provider_id=eddsa-generated), this option is not used (the algorithm is determined by O(config.elliptic_curve)).
- For O(provider_id=aes-generated), this option is not used (AES is always used).
choices:
- RS256
- RS384
- RS512
- PS256
- PS384
- PS512
- RSA1_5
- RSA-OAEP
- RSA-OAEP-256
- HS256
- HS384
- HS512
- ES256
- ES384
- ES512
- AES
- ECDH_ES
- ECDH_ES_A128KW
- ECDH_ES_A192KW
- ECDH_ES_A256KW
- Ed25519
- Ed448
default: RS256
choices: ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'RSA1_5', 'RSA-OAEP', 'RSA-OAEP-256']
type: str
private_key:
description:
- The private key as an ASCII string. Contents of the key must match O(config.algorithm) and O(provider_id).
- Please note that the module cannot detect whether the private key specified differs from the current state's private
key. Use O(force=true) to force the module to update the private key if you expect it to be updated.
required: true
- Required when O(provider_id) is V(rsa) or V(rsa-enc). Not used for auto-generated providers.
type: str
certificate:
description:
@ -110,8 +159,71 @@ options:
and O(provider_id).
- If you want Keycloak to automatically generate a certificate using your private key then set this to an empty
string.
required: true
- Required when O(provider_id) is V(rsa) or V(rsa-enc). Not used for auto-generated providers.
type: str
secret_size:
description:
- The size of the generated secret key in bytes.
- Only applicable to O(provider_id=hmac-generated) and O(provider_id=aes-generated).
- Valid values are V(16), V(24), V(32), V(64), V(128), V(256), V(512).
- Default is V(64) for HMAC, V(16) for AES.
type: int
version_added: 12.4.0
key_size:
description:
- The size of the generated key in bits.
- Only applicable to O(provider_id=rsa-generated) and O(provider_id=rsa-enc-generated).
- Valid values are V(1024), V(2048), V(4096). Default is V(2048).
type: int
version_added: 12.4.0
elliptic_curve:
description:
- The elliptic curve to use for ECDSA, ECDH, or EdDSA keys.
- For O(provider_id=ecdsa-generated) and O(provider_id=ecdh-generated), valid values are V(P-256), V(P-384), V(P-521). Default is V(P-256).
- For O(provider_id=eddsa-generated), valid values are V(Ed25519), V(Ed448). Default is V(Ed25519).
type: str
choices: ['P-256', 'P-384', 'P-521', 'Ed25519', 'Ed448']
version_added: 12.4.0
keystore:
description:
- Path to the Java Keystore file on the Keycloak server filesystem.
- Required when O(provider_id=java-keystore).
type: str
version_added: 12.4.0
keystore_password:
description:
- Password for the Java Keystore.
- Required when O(provider_id=java-keystore).
type: str
version_added: 12.4.0
key_alias:
description:
- Alias of the key within the keystore.
- Required when O(provider_id=java-keystore).
type: str
version_added: 12.4.0
key_password:
description:
- Password for the key within the keystore.
- If not specified, the O(config.keystore_password) is used.
- Only applicable to O(provider_id=java-keystore).
type: str
version_added: 12.4.0
update_password:
description:
- Controls when passwords are sent to Keycloak for V(java-keystore) provider.
- V(always) - Always send passwords. Keycloak will update the component even if passwords
have not changed. Use when you need to ensure passwords are updated.
- V(on_create) - Only send passwords when creating a new component. When updating an
existing component, send the masked value to preserve existing passwords. This makes
the module idempotent for password fields.
- This is necessary because Keycloak masks passwords in API responses (returns C(**********)),
making comparison impossible.
- Has no effect for providers other than V(java-keystore).
type: str
choices: ['always', 'on_create']
default: always
version_added: 12.4.0
notes:
- Current value of the private key cannot be fetched from Keycloak. Therefore comparing its desired state to the current
state is not possible.
@ -119,6 +231,12 @@ notes:
state of the certificate to the desired state (which may be empty) is not possible.
- Due to the private key and certificate options the module is B(not fully idempotent). You can use O(force=true) to force
the module to ensure updating if you know that the private key might have changed.
- For auto-generated providers (V(rsa-generated), V(rsa-enc-generated), V(hmac-generated), V(aes-generated), V(ecdsa-generated),
V(ecdh-generated), V(eddsa-generated)), Keycloak manages the key material automatically. The O(config.private_key) and
O(config.certificate) options are not used.
- For V(java-keystore) provider, the O(config.keystore_password) and O(config.key_password) values are returned masked by
Keycloak. Therefore comparing their current state to the desired state is not possible. Use O(update_password=on_create)
for idempotent playbooks, or use O(update_password=always) (default) if you need to ensure passwords are updated.
extends_documentation_fragment:
- community.general.keycloak
- community.general.keycloak.actiongroup_keycloak
@ -146,6 +264,7 @@ EXAMPLES = r"""
active: true
priority: 120
algorithm: RS256
- name: Manage Keycloak realm key and certificate
community.general.keycloak_realm_key:
name: custom
@ -163,6 +282,178 @@ EXAMPLES = r"""
active: true
priority: 120
algorithm: RS256
- name: Create HMAC signing key (auto-generated)
community.general.keycloak_realm_key:
name: hmac-custom
state: present
parent_id: master
provider_id: hmac-generated
auth_keycloak_url: http://localhost:8080/auth
auth_username: keycloak
auth_password: keycloak
auth_realm: master
config:
enabled: true
active: true
priority: 100
algorithm: HS256
secret_size: 64
- name: Create AES encryption key (auto-generated)
community.general.keycloak_realm_key:
name: aes-custom
state: present
parent_id: master
provider_id: aes-generated
auth_keycloak_url: http://localhost:8080/auth
auth_username: keycloak
auth_password: keycloak
auth_realm: master
config:
enabled: true
active: true
priority: 100
secret_size: 16
- name: Create ECDSA signing key (auto-generated)
community.general.keycloak_realm_key:
name: ecdsa-custom
state: present
parent_id: master
provider_id: ecdsa-generated
auth_keycloak_url: http://localhost:8080/auth
auth_username: keycloak
auth_password: keycloak
auth_realm: master
config:
enabled: true
active: true
priority: 100
algorithm: ES256
elliptic_curve: P-256
- name: Create RSA signing key (auto-generated)
community.general.keycloak_realm_key:
name: rsa-auto
state: present
parent_id: master
provider_id: rsa-generated
auth_keycloak_url: http://localhost:8080/auth
auth_username: keycloak
auth_password: keycloak
auth_realm: master
config:
enabled: true
active: true
priority: 100
algorithm: RS256
key_size: 2048
- name: Remove default HMAC key
community.general.keycloak_realm_key:
name: hmac-generated
state: absent
parent_id: myrealm
provider_id: hmac-generated
auth_keycloak_url: http://localhost:8080/auth
auth_username: keycloak
auth_password: keycloak
auth_realm: master
config:
priority: 100
- name: Create RSA encryption key (auto-generated)
community.general.keycloak_realm_key:
name: rsa-enc-auto
state: present
parent_id: master
provider_id: rsa-enc-generated
auth_keycloak_url: http://localhost:8080/auth
auth_username: keycloak
auth_password: keycloak
auth_realm: master
config:
enabled: true
active: true
priority: 100
algorithm: RSA-OAEP
key_size: 2048
- name: Create ECDH key exchange key (auto-generated)
community.general.keycloak_realm_key:
name: ecdh-custom
state: present
parent_id: master
provider_id: ecdh-generated
auth_keycloak_url: http://localhost:8080/auth
auth_username: keycloak
auth_password: keycloak
auth_realm: master
config:
enabled: true
active: true
priority: 100
algorithm: ECDH_ES
elliptic_curve: P-256
- name: Create EdDSA signing key (auto-generated)
community.general.keycloak_realm_key:
name: eddsa-custom
state: present
parent_id: master
provider_id: eddsa-generated
auth_keycloak_url: http://localhost:8080/auth
auth_username: keycloak
auth_password: keycloak
auth_realm: master
config:
enabled: true
active: true
priority: 100
elliptic_curve: Ed25519
- name: Import key from Java Keystore (always update passwords)
community.general.keycloak_realm_key:
name: jks-imported
state: present
parent_id: master
provider_id: java-keystore
auth_keycloak_url: http://localhost:8080/auth
auth_username: keycloak
auth_password: keycloak
auth_realm: master
# update_password: always is the default - passwords are always sent to Keycloak
config:
enabled: true
active: true
priority: 100
algorithm: RS256
keystore: /opt/keycloak/conf/keystore.jks
keystore_password: "{{ keystore_password }}"
key_alias: mykey
key_password: "{{ key_password }}"
- name: Import key from Java Keystore (idempotent - only set password on create)
community.general.keycloak_realm_key:
name: jks-idempotent
state: present
parent_id: master
provider_id: java-keystore
auth_keycloak_url: http://localhost:8080/auth
auth_username: keycloak
auth_password: keycloak
auth_realm: master
update_password: on_create # Only send passwords when creating, preserve existing on update
config:
enabled: true
active: true
priority: 100
algorithm: RS256
keystore: /opt/keycloak/conf/keystore.jks
keystore_password: "{{ keystore_password }}"
key_alias: mykey
key_password: "{{ key_password }}"
"""
RETURN = r"""
@ -219,8 +510,37 @@ end_state:
"140"
]
}
key_info:
description:
- Cryptographic key metadata fetched from the realm keys endpoint.
- Only returned for V(java-keystore) provider when O(state=present) and not in check mode.
- This includes the key ID (kid) and certificate fingerprint, which can be used to detect
if the actual cryptographic key changed.
type: dict
returned: when O(provider_id=java-keystore) and O(state=present)
version_added: 12.4.0
contains:
kid:
description: The key ID (kid) - unique identifier for the cryptographic key.
type: str
sample: bN7p5Nc_V2M7N_-mb5vVSRVPKq5qD_OuARInB9ofsJ0
certificate_fingerprint:
description: SHA256 fingerprint of the certificate in colon-separated hex format.
type: str
sample: "A1:B2:C3:D4:E5:F6:..."
status:
description: The key status (ACTIVE, PASSIVE, DISABLED).
type: str
sample: ACTIVE
valid_to:
description: Certificate expiration timestamp in milliseconds since epoch.
type: int
sample: 1801789047000
"""
import base64
import binascii
import hashlib
from copy import deepcopy
from urllib.parse import urlencode
@ -234,6 +554,113 @@ from ansible_collections.community.general.plugins.module_utils.identity.keycloa
keycloak_argument_spec,
)
# Provider IDs that require private_key and certificate
IMPORTED_KEY_PROVIDERS = ["rsa", "rsa-enc"]
# Provider IDs that import keys from Java Keystore
KEYSTORE_PROVIDERS = ["java-keystore"]
# Provider IDs that auto-generate keys
GENERATED_KEY_PROVIDERS = [
"rsa-generated",
"rsa-enc-generated",
"hmac-generated",
"aes-generated",
"ecdsa-generated",
"ecdh-generated",
"eddsa-generated",
]
# Mapping of Ansible parameter names to Keycloak config property names
# for cases where camel() conversion doesn't produce the correct result.
# Each provider type may use a different config key for elliptic curve.
CONFIG_PARAM_MAPPING = {
"elliptic_curve": "ecdsaEllipticCurveKey",
}
# Provider-specific config key names for elliptic_curve parameter
# ECDSA and ECDH both use the same curves (P-256, P-384, P-521) but different config keys
# EdDSA uses different curves (Ed25519, Ed448) with its own config key
ELLIPTIC_CURVE_CONFIG_KEYS = {
"ecdsa-generated": "ecdsaEllipticCurveKey",
"ecdh-generated": "ecdhEllipticCurveKey",
"eddsa-generated": "eddsaEllipticCurveKey",
}
# Valid algorithm choices per provider type
# Note: aes-generated and eddsa-generated don't use algorithm config
PROVIDER_ALGORITHMS = {
"rsa": ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"],
"rsa-enc": ["RSA1_5", "RSA-OAEP", "RSA-OAEP-256"],
"java-keystore": ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"],
"rsa-generated": ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"],
"rsa-enc-generated": ["RSA1_5", "RSA-OAEP", "RSA-OAEP-256"],
"hmac-generated": ["HS256", "HS384", "HS512"],
"ecdsa-generated": ["ES256", "ES384", "ES512"],
"ecdh-generated": ["ECDH_ES", "ECDH_ES_A128KW", "ECDH_ES_A192KW", "ECDH_ES_A256KW"],
}
# Providers that don't use the algorithm config parameter
# eddsa-generated: algorithm is determined by the elliptic curve (Ed25519 or Ed448)
# aes-generated: always uses AES algorithm
PROVIDERS_WITHOUT_ALGORITHM = ["aes-generated", "eddsa-generated"]
# Providers where the RS256 default is valid (for backward compatibility)
PROVIDERS_WITH_RS256_DEFAULT = ["rsa", "rsa-generated", "java-keystore"]
# Config keys that cannot be compared and must be removed from changesets/diffs.
# privateKey/certificate: Keycloak doesn't return private keys, certificates are generated dynamically.
# keystorePassword/keyPassword: Keycloak masks these with "**********" in API responses.
SENSITIVE_CONFIG_KEYS = ["privateKey", "certificate", "keystorePassword", "keyPassword"]
def remove_sensitive_config_keys(config):
for key in SENSITIVE_CONFIG_KEYS:
config.pop(key, None)
def get_keycloak_config_key(param_name, provider_id=None):
"""Convert Ansible parameter name to Keycloak config key.
Uses explicit mapping if available, otherwise applies camelCase conversion.
For elliptic_curve, the config key depends on the provider type.
"""
# Handle elliptic_curve specially - each provider uses a different config key
if param_name == "elliptic_curve" and provider_id in ELLIPTIC_CURVE_CONFIG_KEYS:
return ELLIPTIC_CURVE_CONFIG_KEYS[param_name]
if param_name in CONFIG_PARAM_MAPPING:
return CONFIG_PARAM_MAPPING[param_name]
return camel(param_name)
def compute_certificate_fingerprint(certificate_pem):
try:
cert_der = base64.b64decode(certificate_pem)
fingerprint = hashlib.sha256(cert_der).hexdigest().upper()
return ":".join(fingerprint[i : i + 2] for i in range(0, len(fingerprint), 2))
except (ValueError, binascii.Error, TypeError):
return None
def get_key_info_for_component(kc, realm, component_id):
try:
keys_response = kc.get_realm_keys_metadata_by_id(realm)
if not keys_response or "keys" not in keys_response:
return None
for key in keys_response.get("keys", []):
if key.get("providerId") == component_id:
return {
"kid": key.get("kid"),
"certificate_fingerprint": compute_certificate_fingerprint(key.get("certificate")),
"public_key": key.get("publicKey"),
"valid_to": key.get("validTo"),
"status": key.get("status"),
"algorithm": key.get("algorithm"),
"type": key.get("type"),
}
return None
except (KeyError, TypeError):
return None
def main():
"""
@ -248,7 +675,22 @@ def main():
name=dict(type="str", required=True),
force=dict(type="bool", default=False),
parent_id=dict(type="str", required=True),
provider_id=dict(type="str", default="rsa", choices=["rsa", "rsa-enc"]),
provider_id=dict(
type="str",
default="rsa",
choices=[
"rsa",
"rsa-enc",
"java-keystore",
"rsa-generated",
"rsa-enc-generated",
"hmac-generated",
"aes-generated",
"ecdsa-generated",
"ecdh-generated",
"eddsa-generated",
],
),
config=dict(
type="dict",
options=dict(
@ -268,12 +710,38 @@ def main():
"RSA1_5",
"RSA-OAEP",
"RSA-OAEP-256",
"HS256",
"HS384",
"HS512",
"ES256",
"ES384",
"ES512",
"AES",
"ECDH_ES",
"ECDH_ES_A128KW",
"ECDH_ES_A192KW",
"ECDH_ES_A256KW",
"Ed25519",
"Ed448",
],
),
private_key=dict(type="str", required=True, no_log=True),
certificate=dict(type="str", required=True),
private_key=dict(type="str", no_log=True),
certificate=dict(type="str"),
secret_size=dict(type="int", no_log=False),
key_size=dict(type="int"),
elliptic_curve=dict(type="str", choices=["P-256", "P-384", "P-521", "Ed25519", "Ed448"]),
keystore=dict(type="str", no_log=False),
keystore_password=dict(type="str", no_log=True),
key_alias=dict(type="str", no_log=False),
key_password=dict(type="str", no_log=True),
),
),
update_password=dict(
type="str",
default="always",
choices=["always", "on_create"],
no_log=False,
),
)
argument_spec.update(meta_args)
@ -288,8 +756,61 @@ def main():
required_by={"refresh_token": "auth_realm"},
)
# Initialize the result object. Only "changed" seems to have special
# meaning for Ansible.
provider_id = module.params["provider_id"]
config = module.params["config"] or {}
state = module.params["state"]
# Validate that imported key providers have the required parameters
if state == "present" and provider_id in IMPORTED_KEY_PROVIDERS:
if not config.get("private_key"):
module.fail_json(msg=f"config.private_key is required for provider_id '{provider_id}'")
if config.get("certificate") is None:
module.fail_json(
msg=f"config.certificate is required for provider_id '{provider_id}' (use empty string for auto-generation)"
)
# Validate that java-keystore providers have the required parameters
if state == "present" and provider_id in KEYSTORE_PROVIDERS:
required_params = ["keystore", "keystore_password", "key_alias"]
missing = [p for p in required_params if not config.get(p)]
if missing:
module.fail_json(
msg=f"For provider_id=java-keystore, the following config options are required: {', '.join(missing)}"
)
# Validate algorithm for providers that use it
if state == "present":
algorithm = config.get("algorithm")
if provider_id in PROVIDER_ALGORITHMS:
valid_algorithms = PROVIDER_ALGORITHMS[provider_id]
if algorithm not in valid_algorithms:
msg = f"algorithm '{algorithm}' is not valid for provider_id '{provider_id}'."
if algorithm == "RS256" and provider_id not in PROVIDERS_WITH_RS256_DEFAULT:
msg += " The default 'RS256' is not valid for this provider."
msg += f" Valid choices are: {', '.join(valid_algorithms)}"
module.fail_json(msg=msg)
elif provider_id in PROVIDERS_WITHOUT_ALGORITHM and algorithm is not None and algorithm != "RS256":
# aes-generated and eddsa-generated don't use algorithm - only warn if user explicitly set a non-default value
module.warn(f"algorithm is ignored for provider_id '{provider_id}'")
# Validate elliptic curve for providers that use it
if state == "present":
elliptic_curve = config.get("elliptic_curve")
if provider_id in ["ecdsa-generated", "ecdh-generated"] and elliptic_curve is not None:
valid_curves = ["P-256", "P-384", "P-521"]
if elliptic_curve not in valid_curves:
module.fail_json(
msg=f"elliptic_curve '{elliptic_curve}' is not valid for provider_id '{provider_id}'. "
f"Valid choices are: {', '.join(valid_curves)}"
)
elif provider_id == "eddsa-generated" and elliptic_curve is not None:
valid_curves = ["Ed25519", "Ed448"]
if elliptic_curve not in valid_curves:
module.fail_json(
msg=f"elliptic_curve '{elliptic_curve}' is not valid for provider_id '{provider_id}'. "
f"Valid choices are: {', '.join(valid_curves)}"
)
result = dict(changed=False, msg="", end_state={}, diff=dict(before={}, after={}))
# This will include the current state of the realm key if it is already
@ -305,7 +826,7 @@ def main():
kc = KeycloakAPI(module, connection_header)
params_to_ignore = list(keycloak_argument_spec().keys()) + ["state", "force", "parent_id"]
params_to_ignore = list(keycloak_argument_spec().keys()) + ["state", "force", "parent_id", "update_password"]
# Filter and map the parameters names that apply to the role
component_params = [x for x in module.params if x not in params_to_ignore and module.params.get(x) is not None]
@ -332,18 +853,25 @@ def main():
#
for component_param in component_params:
if component_param == "config":
for config_param in module.params.get("config"):
changeset["config"][camel(config_param)] = []
raw_value = module.params.get("config")[config_param]
for config_param in module.params["config"]:
raw_value = module.params["config"][config_param]
# Optional params (secret_size, key_size, elliptic_curve) default to None.
# Skip them to avoid sending str(None) = "None" as a config value to Keycloak.
if raw_value is None:
continue
# Use custom mapping if available, otherwise camelCase
# Pass provider_id for elliptic_curve which uses different config keys per provider
keycloak_key = get_keycloak_config_key(config_param, provider_id)
changeset["config"][keycloak_key] = []
if isinstance(raw_value, bool):
value = str(raw_value).lower()
else:
value = str(raw_value)
changeset["config"][camel(config_param)].append(value)
changeset["config"][keycloak_key].append(value)
else:
# No need for camelcase in here as these are one word parameters
new_param_value = module.params.get(component_param)
new_param_value = module.params[component_param]
changeset[camel(component_param)] = new_param_value
# As provider_type is not a module parameter we have to add it to the
@ -354,22 +882,14 @@ def main():
# changes to the current state.
changeset_copy = deepcopy(changeset)
# It is not possible to compare current keys to desired keys, because the
# certificate parameter is a base64-encoded binary blob created on the fly
# when a key is added. Moreover, the Keycloak Admin API does not seem to
# return the value of the private key for comparison. So, in effect, it we
# just have to ignore changes to the keys. However, as the privateKey
# parameter needs be present in the JSON payload, any changes done to any
# other parameters (e.g. config.priority) will trigger update of the keys
# as a side-effect.
del changeset_copy["config"]["privateKey"]
del changeset_copy["config"]["certificate"]
# Remove keys that cannot be compared: privateKey/certificate (not returned
# by Keycloak API) and keystore passwords (masked with "**********").
# The actual values remain in 'changeset' for the API payload.
remove_sensitive_config_keys(changeset_copy["config"])
# Make it easier to refer to current module parameters
name = module.params.get("name")
force = module.params.get("force")
state = module.params.get("state")
parent_id = module.params.get("parent_id")
name = module.params["name"]
force = module.params["force"]
parent_id = module.params["parent_id"]
# Get a list of all Keycloak components that are of keyprovider type.
realm_keys = kc.get_components(urlencode(dict(type=provider_type)), parent_id)
@ -407,7 +927,7 @@ def main():
# gracefully by using .get() with defaults.
for p, v in changeset_copy["config"].items():
# Get the current value, defaulting to our expected value if not present
# This handles the case where Keycloak does not return certain fields
# This handles the case where Keycloak doesn't return certain fields
# for default/generated keys
current_value = key["config"].get(p, v)
before_realm_key["config"][p] = current_value
@ -415,10 +935,37 @@ def main():
changes += f"config.{p}: {current_value} -> {v}, "
result["changed"] = True
# Sanitize linefeeds for the privateKey. Without this the JSON payload
# will be invalid.
changeset["config"]["privateKey"][0] = changeset["config"]["privateKey"][0].replace("\\n", "\n")
changeset["config"]["certificate"][0] = changeset["config"]["certificate"][0].replace("\\n", "\n")
# For java-keystore provider, also fetch and compare key info (kid)
# This detects if the actual cryptographic key changed even when
# other config parameters remain the same
if provider_id in KEYSTORE_PROVIDERS:
current_key_info = get_key_info_for_component(kc, parent_id, key_id)
if current_key_info:
before_realm_key["key_info"] = {
"kid": current_key_info.get("kid"),
"certificate_fingerprint": current_key_info.get("certificate_fingerprint"),
}
# Sanitize linefeeds for the privateKey and certificate (only for imported providers).
# Without this the JSON payload will be invalid.
if "privateKey" in changeset["config"]:
changeset["config"]["privateKey"][0] = changeset["config"]["privateKey"][0].replace("\\n", "\n")
if "certificate" in changeset["config"]:
changeset["config"]["certificate"][0] = changeset["config"]["certificate"][0].replace("\\n", "\n")
# For java-keystore provider: handle update_password parameter
# When update_password=on_create and we're updating an existing component,
# replace actual passwords with the masked value ("**********") that Keycloak
# returns in API responses. When Keycloak receives this masked value, it
# preserves the existing password instead of updating it.
# This makes the module idempotent for password fields.
update_password = module.params["update_password"]
if provider_id in KEYSTORE_PROVIDERS and key_id and update_password == "on_create":
SECRET_VALUE = "**********"
if "keystorePassword" in changeset["config"]:
changeset["config"]["keystorePassword"] = [SECRET_VALUE]
if "keyPassword" in changeset["config"]:
changeset["config"]["keyPassword"] = [SECRET_VALUE]
# Check all the possible states of the resource and do what is needed to
# converge current state with desired state (create, update or delete
@ -426,8 +973,7 @@ def main():
if key_id and state == "present":
if result["changed"]:
if module._diff:
del before_realm_key["config"]["privateKey"]
del before_realm_key["config"]["certificate"]
remove_sensitive_config_keys(before_realm_key["config"])
result["diff"] = dict(before=before_realm_key, after=changeset_copy)
if module.check_mode:
@ -443,10 +989,26 @@ def main():
result["msg"] = f"Realm key {name} was in sync"
result["end_state"] = changeset_copy
# For java-keystore provider, include key info in end_state
if provider_id in KEYSTORE_PROVIDERS:
if not module.check_mode:
key_info = get_key_info_for_component(kc, parent_id, key_id)
if key_info:
result["end_state"]["key_info"] = {
"kid": key_info.get("kid"),
"certificate_fingerprint": key_info.get("certificate_fingerprint"),
"status": key_info.get("status"),
"valid_to": key_info.get("valid_to"),
}
else:
module.warn(
f"Key component '{name}' exists but no active key was found. "
"This may indicate an incorrect keystore password, path, or alias."
)
elif key_id and state == "absent":
if module._diff:
del before_realm_key["config"]["privateKey"]
del before_realm_key["config"]["certificate"]
remove_sensitive_config_keys(before_realm_key["config"])
result["diff"] = dict(before=before_realm_key, after={})
if module.check_mode:
@ -470,6 +1032,28 @@ def main():
result["changed"] = True
result["msg"] = f"Realm key {name} created"
# For java-keystore provider, fetch and include key info after creation
if provider_id in KEYSTORE_PROVIDERS:
# We need to get the component ID first (it was just created)
realm_keys_after = kc.get_components(urlencode(dict(type=provider_type)), parent_id)
for k in realm_keys_after:
if k["name"] == name:
new_key_id = k["id"]
key_info = get_key_info_for_component(kc, parent_id, new_key_id)
if key_info:
changeset_copy["key_info"] = {
"kid": key_info.get("kid"),
"certificate_fingerprint": key_info.get("certificate_fingerprint"),
"status": key_info.get("status"),
"valid_to": key_info.get("valid_to"),
}
else:
module.warn(
f"Key component '{name}' was created but no active key was found. "
"This may indicate an incorrect keystore password, path, or alias."
)
break
result["end_state"] = changeset_copy
elif not key_id and state == "absent":
result["changed"] = False