diff --git a/changelogs/fragments/keycloak-realm-key-generated-providers.yml b/changelogs/fragments/keycloak-realm-key-generated-providers.yml new file mode 100644 index 0000000000..c8a011a239 --- /dev/null +++ b/changelogs/fragments/keycloak-realm-key-generated-providers.yml @@ -0,0 +1,2 @@ +minor_changes: + - keycloak_realm_key - add support for auto-generated key providers (``rsa-generated``, ``rsa-enc-generated``, ``hmac-generated``, ``aes-generated``, ``ecdsa-generated``, ``ecdh-generated``, ``eddsa-generated``), ``java-keystore`` provider, additional algorithms (HMAC, ECDSA, ECDH, EdDSA, AES), and new config options (``secret_size``, ``key_size``, ``elliptic_curve``, ``keystore``, ``keystore_password``, ``key_alias``, ``key_password``). Also makes ``config.private_key`` and ``config.certificate`` optional as they are only required for imported key providers (https://github.com/ansible-collections/community.general/pull/11468). diff --git a/plugins/modules/keycloak_realm_key.py b/plugins/modules/keycloak_realm_key.py index e59228fe39..3b5c631132 100644 --- a/plugins/modules/keycloak_realm_key.py +++ b/plugins/modules/keycloak_realm_key.py @@ -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 diff --git a/tests/integration/targets/keycloak_realm_key/tasks/main.yml b/tests/integration/targets/keycloak_realm_key/tasks/main.yml index e447d075e0..f95b55dd7a 100644 --- a/tests/integration/targets/keycloak_realm_key/tasks/main.yml +++ b/tests/integration/targets/keycloak_realm_key/tasks/main.yml @@ -364,6 +364,996 @@ - result.end_state.config.priority == ["150"] - result.msg == "Realm key testkey_with_certificate was in sync" +# ============================================================ +# Tests for auto-generated key providers +# ============================================================ + +- name: Create HMAC key (hmac-generated provider, check mode) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: hmac-test-key + state: present + parent_id: "{{ realm }}" + provider_id: hmac-generated + config: + enabled: true + active: true + priority: 100 + algorithm: HS256 + secret_size: 64 + check_mode: true + register: result + +- name: Assert HMAC key would be created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "hmac-test-key" + - result.end_state.providerId == "hmac-generated" + - result.end_state.config.algorithm == ["HS256"] + - result.end_state.config.secretSize == ["64"] + - result.msg == "Realm key hmac-test-key would be created" + +- name: Create HMAC key (hmac-generated provider) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: hmac-test-key + state: present + parent_id: "{{ realm }}" + provider_id: hmac-generated + config: + enabled: true + active: true + priority: 100 + algorithm: HS256 + secret_size: 64 + register: result + +- name: Assert HMAC key was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "hmac-test-key" + - result.end_state.providerId == "hmac-generated" + - result.end_state.providerType == "org.keycloak.keys.KeyProvider" + - result.end_state.config.algorithm == ["HS256"] + - result.end_state.config.secretSize == ["64"] + - result.msg == "Realm key hmac-test-key created" + +- name: Create HMAC key (test for idempotency) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: hmac-test-key + state: present + parent_id: "{{ realm }}" + provider_id: hmac-generated + config: + enabled: true + active: true + priority: 100 + algorithm: HS256 + secret_size: 64 + register: result + +- name: Assert HMAC key is in sync + assert: + that: + - result is not changed + - result.msg == "Realm key hmac-test-key was in sync" + +- name: Update HMAC key priority + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: hmac-test-key + state: present + parent_id: "{{ realm }}" + provider_id: hmac-generated + config: + enabled: true + active: true + priority: 110 + algorithm: HS256 + secret_size: 64 + register: result + +- name: Assert HMAC key was updated + assert: + that: + - result is changed + - result.end_state.config.priority == ["110"] + - "'config.priority' in result.msg" + +- name: Remove HMAC key + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: hmac-test-key + state: absent + parent_id: "{{ realm }}" + provider_id: hmac-generated + config: + priority: 110 + register: result + +- name: Assert HMAC key was deleted + assert: + that: + - result is changed + - result.end_state == {} + - result.msg == "Realm key hmac-test-key deleted" + +# ============================================================ +# AES generated key tests +# ============================================================ + +- name: Create AES key (aes-generated provider) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: aes-test-key + state: present + parent_id: "{{ realm }}" + provider_id: aes-generated + config: + enabled: true + active: true + priority: 100 + secret_size: 32 + register: result + +- name: Assert AES key was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "aes-test-key" + - result.end_state.providerId == "aes-generated" + - result.end_state.config.secretSize == ["32"] + - result.msg == "Realm key aes-test-key created" + +- name: Create AES key (test for idempotency) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: aes-test-key + state: present + parent_id: "{{ realm }}" + provider_id: aes-generated + config: + enabled: true + active: true + priority: 100 + secret_size: 32 + register: result + +- name: Assert AES key is in sync + assert: + that: + - result is not changed + - result.msg == "Realm key aes-test-key was in sync" + +- name: Remove AES key + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: aes-test-key + state: absent + parent_id: "{{ realm }}" + provider_id: aes-generated + config: + priority: 100 + register: result + +- name: Assert AES key was deleted + assert: + that: + - result is changed + - result.msg == "Realm key aes-test-key deleted" + +# ============================================================ +# ECDSA generated key tests +# ============================================================ + +- name: Create ECDSA key (ecdsa-generated provider) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: ecdsa-test-key + state: present + parent_id: "{{ realm }}" + provider_id: ecdsa-generated + config: + enabled: true + active: true + priority: 100 + algorithm: ES256 + elliptic_curve: P-256 + register: result + +- name: Assert ECDSA key was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "ecdsa-test-key" + - result.end_state.providerId == "ecdsa-generated" + - result.end_state.config.algorithm == ["ES256"] + - result.end_state.config.ecdsaEllipticCurveKey == ["P-256"] + - result.msg == "Realm key ecdsa-test-key created" + +- name: Create ECDSA key (test for idempotency) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: ecdsa-test-key + state: present + parent_id: "{{ realm }}" + provider_id: ecdsa-generated + config: + enabled: true + active: true + priority: 100 + algorithm: ES256 + elliptic_curve: P-256 + register: result + +- name: Assert ECDSA key is in sync + assert: + that: + - result is not changed + - result.msg == "Realm key ecdsa-test-key was in sync" + +- name: Remove ECDSA key + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: ecdsa-test-key + state: absent + parent_id: "{{ realm }}" + provider_id: ecdsa-generated + config: + priority: 100 + register: result + +- name: Assert ECDSA key was deleted + assert: + that: + - result is changed + - result.msg == "Realm key ecdsa-test-key deleted" + +# ============================================================ +# RSA generated key tests +# ============================================================ + +- name: Create RSA generated key (rsa-generated provider) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: rsa-gen-test-key + state: present + parent_id: "{{ realm }}" + provider_id: rsa-generated + config: + enabled: true + active: true + priority: 100 + algorithm: RS256 + key_size: 2048 + register: result + +- name: Assert RSA generated key was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "rsa-gen-test-key" + - result.end_state.providerId == "rsa-generated" + - result.end_state.config.algorithm == ["RS256"] + - result.end_state.config.keySize == ["2048"] + - result.msg == "Realm key rsa-gen-test-key created" + +- name: Create RSA generated key (test for idempotency) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: rsa-gen-test-key + state: present + parent_id: "{{ realm }}" + provider_id: rsa-generated + config: + enabled: true + active: true + priority: 100 + algorithm: RS256 + key_size: 2048 + register: result + +- name: Assert RSA generated key is in sync + assert: + that: + - result is not changed + - result.msg == "Realm key rsa-gen-test-key was in sync" + +- name: Remove RSA generated key + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: rsa-gen-test-key + state: absent + parent_id: "{{ realm }}" + provider_id: rsa-generated + config: + priority: 100 + register: result + +- name: Assert RSA generated key was deleted + assert: + that: + - result is changed + - result.msg == "Realm key rsa-gen-test-key deleted" + +# ============================================================ +# Test managing default realm keys (issue #11459) +# ============================================================ + +- name: Update priority of default hmac-generated key + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: hmac-generated + state: present + parent_id: "{{ realm }}" + provider_id: hmac-generated + config: + enabled: true + active: true + priority: 150 + register: result + +- name: Assert default hmac-generated key was updated + assert: + that: + - result is changed + - result.end_state.config.priority == ["150"] + +- name: Remove default hmac-generated key + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: hmac-generated + state: absent + parent_id: "{{ realm }}" + provider_id: hmac-generated + config: + priority: 150 + register: result + +- name: Assert default hmac-generated key was deleted + assert: + that: + - result is changed + - result.end_state == {} + - result.msg == "Realm key hmac-generated deleted" + +# ============================================================ +# RSA encryption generated key tests (rsa-enc-generated) +# ============================================================ + +- name: Create RSA encryption key (rsa-enc-generated provider) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: rsa-enc-gen-test-key + state: present + parent_id: "{{ realm }}" + provider_id: rsa-enc-generated + config: + enabled: true + active: true + priority: 100 + algorithm: RSA-OAEP + key_size: 2048 + register: result + +- name: Assert RSA encryption key was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "rsa-enc-gen-test-key" + - result.end_state.providerId == "rsa-enc-generated" + - result.end_state.config.algorithm == ["RSA-OAEP"] + - result.end_state.config.keySize == ["2048"] + - result.msg == "Realm key rsa-enc-gen-test-key created" + +- name: Create RSA encryption key (test for idempotency) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: rsa-enc-gen-test-key + state: present + parent_id: "{{ realm }}" + provider_id: rsa-enc-generated + config: + enabled: true + active: true + priority: 100 + algorithm: RSA-OAEP + key_size: 2048 + register: result + +- name: Assert RSA encryption key is in sync + assert: + that: + - result is not changed + - result.msg == "Realm key rsa-enc-gen-test-key was in sync" + +- name: Remove RSA encryption key + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: rsa-enc-gen-test-key + state: absent + parent_id: "{{ realm }}" + provider_id: rsa-enc-generated + config: + priority: 100 + register: result + +- name: Assert RSA encryption key was deleted + assert: + that: + - result is changed + - result.msg == "Realm key rsa-enc-gen-test-key deleted" + +# ============================================================ +# ECDH generated key tests (ecdh-generated) +# ============================================================ + +- name: Create ECDH key (ecdh-generated provider) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: ecdh-test-key + state: present + parent_id: "{{ realm }}" + provider_id: ecdh-generated + config: + enabled: true + active: true + priority: 100 + algorithm: ECDH_ES + elliptic_curve: P-256 + register: result + +- name: Assert ECDH key was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "ecdh-test-key" + - result.end_state.providerId == "ecdh-generated" + - result.end_state.config.algorithm == ["ECDH_ES"] + - result.end_state.config.ecdhEllipticCurveKey == ["P-256"] + - result.msg == "Realm key ecdh-test-key created" + +- name: Create ECDH key (test for idempotency) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: ecdh-test-key + state: present + parent_id: "{{ realm }}" + provider_id: ecdh-generated + config: + enabled: true + active: true + priority: 100 + algorithm: ECDH_ES + elliptic_curve: P-256 + register: result + +- name: Assert ECDH key is in sync + assert: + that: + - result is not changed + - result.msg == "Realm key ecdh-test-key was in sync" + +- name: Remove ECDH key + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: ecdh-test-key + state: absent + parent_id: "{{ realm }}" + provider_id: ecdh-generated + config: + priority: 100 + register: result + +- name: Assert ECDH key was deleted + assert: + that: + - result is changed + - result.msg == "Realm key ecdh-test-key deleted" + +# ============================================================ +# EdDSA generated key tests (eddsa-generated) +# ============================================================ + +- name: Create EdDSA key (eddsa-generated provider) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: eddsa-test-key + state: present + parent_id: "{{ realm }}" + provider_id: eddsa-generated + config: + enabled: true + active: true + priority: 100 + elliptic_curve: Ed25519 + register: result + +- name: Assert EdDSA key was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "eddsa-test-key" + - result.end_state.providerId == "eddsa-generated" + - result.end_state.config.eddsaEllipticCurveKey == ["Ed25519"] + - result.msg == "Realm key eddsa-test-key created" + +- name: Create EdDSA key (test for idempotency) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: eddsa-test-key + state: present + parent_id: "{{ realm }}" + provider_id: eddsa-generated + config: + enabled: true + active: true + priority: 100 + elliptic_curve: Ed25519 + register: result + +- name: Assert EdDSA key is in sync + assert: + that: + - result is not changed + - result.msg == "Realm key eddsa-test-key was in sync" + +- name: Remove EdDSA key + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: eddsa-test-key + state: absent + parent_id: "{{ realm }}" + provider_id: eddsa-generated + config: + priority: 100 + register: result + +- name: Assert EdDSA key was deleted + assert: + that: + - result is changed + - result.msg == "Realm key eddsa-test-key deleted" + +# ============================================================ +# Java Keystore provider tests (java-keystore) +# Note: These tests require a keystore file on the Keycloak server +# They are conditionally skipped if test_keystore_path is not defined +# ============================================================ + +- name: Create java-keystore key (check mode) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: jks-test-key + state: present + parent_id: "{{ realm }}" + provider_id: java-keystore + config: + enabled: true + active: true + priority: 100 + algorithm: RS256 + keystore: "{{ test_keystore_path }}" + keystore_password: "{{ test_keystore_password }}" + key_alias: "{{ test_key_alias }}" + check_mode: true + register: result + when: test_keystore_path is defined + +- name: Assert java-keystore key would be created (check mode) + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "jks-test-key" + - result.end_state.providerId == "java-keystore" + - result.end_state.config.algorithm == ["RS256"] + - result.end_state.config.keystore == [test_keystore_path] + - result.end_state.config.keyAlias == [test_key_alias] + - result.msg == "Realm key jks-test-key would be created" + when: test_keystore_path is defined + +- name: Create java-keystore key + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: jks-test-key + state: present + parent_id: "{{ realm }}" + provider_id: java-keystore + config: + enabled: true + active: true + priority: 100 + algorithm: RS256 + keystore: "{{ test_keystore_path }}" + keystore_password: "{{ test_keystore_password }}" + key_alias: "{{ test_key_alias }}" + register: result + when: test_keystore_path is defined + +- name: Assert java-keystore key was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "jks-test-key" + - result.end_state.providerId == "java-keystore" + - result.end_state.providerType == "org.keycloak.keys.KeyProvider" + - result.end_state.config.algorithm == ["RS256"] + - result.end_state.key_info is defined + - result.end_state.key_info.kid is defined + - result.end_state.key_info.certificate_fingerprint is defined + - result.end_state.key_info.status == "ACTIVE" + - result.msg == "Realm key jks-test-key created" + when: test_keystore_path is defined + +- name: Create java-keystore key (test for idempotency) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: jks-test-key + state: present + parent_id: "{{ realm }}" + provider_id: java-keystore + config: + enabled: true + active: true + priority: 100 + algorithm: RS256 + keystore: "{{ test_keystore_path }}" + keystore_password: "{{ test_keystore_password }}" + key_alias: "{{ test_key_alias }}" + register: result + when: test_keystore_path is defined + +- name: Assert java-keystore key is in sync + assert: + that: + - result is not changed + - result.msg == "Realm key jks-test-key was in sync" + when: test_keystore_path is defined + +- name: Update java-keystore key priority + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: jks-test-key + state: present + parent_id: "{{ realm }}" + provider_id: java-keystore + config: + enabled: true + active: true + priority: 110 + algorithm: RS256 + keystore: "{{ test_keystore_path }}" + keystore_password: "{{ test_keystore_password }}" + key_alias: "{{ test_key_alias }}" + register: result + when: test_keystore_path is defined + +- name: Assert java-keystore key was updated + assert: + that: + - result is changed + - result.end_state.config.priority == ["110"] + - "'config.priority' in result.msg" + when: test_keystore_path is defined + +- name: Remove java-keystore key + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: jks-test-key + state: absent + parent_id: "{{ realm }}" + provider_id: java-keystore + config: + priority: 110 + register: result + when: test_keystore_path is defined + +- name: Assert java-keystore key was deleted + assert: + that: + - result is changed + - result.end_state == {} + - result.msg == "Realm key jks-test-key deleted" + when: test_keystore_path is defined + +# ============================================================ +# Java Keystore update_password tests +# ============================================================ + +- name: Create java-keystore key with update_password=always (default) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: jks-update-pw-test + state: present + parent_id: "{{ realm }}" + provider_id: java-keystore + # update_password: always is the default + config: + enabled: true + active: true + priority: 100 + algorithm: RS256 + keystore: "{{ test_keystore_path }}" + keystore_password: "{{ test_keystore_password }}" + key_alias: "{{ test_key_alias }}" + register: result + when: test_keystore_path is defined + +- name: Assert java-keystore key was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "jks-update-pw-test" + - result.msg == "Realm key jks-update-pw-test created" + when: test_keystore_path is defined + +- name: Re-run with update_password=always (should NOT be idempotent - passwords always sent) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: jks-update-pw-test + state: present + parent_id: "{{ realm }}" + provider_id: java-keystore + update_password: always + config: + enabled: true + active: true + priority: 100 + algorithm: RS256 + keystore: "{{ test_keystore_path }}" + keystore_password: "{{ test_keystore_password }}" + key_alias: "{{ test_key_alias }}" + register: result + when: test_keystore_path is defined + +# Note: With update_password=always, the module always sends passwords to Keycloak. +# Keycloak doesn't report back if passwords changed, so the module reports "in sync" +# for the config comparison (passwords are excluded from comparison). +# The key difference is: always sends real passwords, on_create sends masked values. +- name: Assert java-keystore key is in sync (no config changes detected) + assert: + that: + - result is not changed + - result.msg == "Realm key jks-update-pw-test was in sync" + when: test_keystore_path is defined + +- name: Remove java-keystore key to test update_password=on_create + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: jks-update-pw-test + state: absent + parent_id: "{{ realm }}" + provider_id: java-keystore + config: + priority: 100 + register: result + when: test_keystore_path is defined + +- name: Create java-keystore key with update_password=on_create + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: jks-update-pw-test + state: present + parent_id: "{{ realm }}" + provider_id: java-keystore + update_password: on_create + config: + enabled: true + active: true + priority: 100 + algorithm: RS256 + keystore: "{{ test_keystore_path }}" + keystore_password: "{{ test_keystore_password }}" + key_alias: "{{ test_key_alias }}" + register: result + when: test_keystore_path is defined + +- name: Assert java-keystore key was created with on_create mode + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "jks-update-pw-test" + - result.msg == "Realm key jks-update-pw-test created" + when: test_keystore_path is defined + +- name: Re-run with update_password=on_create (should be idempotent) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: jks-update-pw-test + state: present + parent_id: "{{ realm }}" + provider_id: java-keystore + update_password: on_create + config: + enabled: true + active: true + priority: 100 + algorithm: RS256 + keystore: "{{ test_keystore_path }}" + keystore_password: "{{ test_keystore_password }}" + key_alias: "{{ test_key_alias }}" + register: result + when: test_keystore_path is defined + +- name: Assert java-keystore key is idempotent with on_create mode + assert: + that: + - result is not changed + - result.msg == "Realm key jks-update-pw-test was in sync" + when: test_keystore_path is defined + +- name: Update priority with update_password=on_create (passwords preserved) + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: jks-update-pw-test + state: present + parent_id: "{{ realm }}" + provider_id: java-keystore + update_password: on_create + config: + enabled: true + active: true + priority: 110 + algorithm: RS256 + keystore: "{{ test_keystore_path }}" + keystore_password: "{{ test_keystore_password }}" + key_alias: "{{ test_key_alias }}" + register: result + when: test_keystore_path is defined + +- name: Assert priority was updated but passwords preserved + assert: + that: + - result is changed + - result.end_state.config.priority == ["110"] + - "'config.priority' in result.msg" + when: test_keystore_path is defined + +- name: Remove java-keystore update_password test key + community.general.keycloak_realm_key: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: jks-update-pw-test + state: absent + parent_id: "{{ realm }}" + provider_id: java-keystore + config: + priority: 110 + register: result + when: test_keystore_path is defined + +- name: Assert java-keystore update_password test key was deleted + assert: + that: + - result is changed + - result.end_state == {} + when: test_keystore_path is defined + - name: Remove Keycloak test realm community.general.keycloak_realm: auth_keycloak_url: "{{ url }}"