#!/usr/bin/python3
#
# Univention Keycloak
#
# SPDX-FileCopyrightText: 2022-2025 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only
"""Manage Univention Keycloak app"""

from __future__ import annotations

import json
import subprocess
import sys
import warnings
from argparse import SUPPRESS, Action, ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace
from base64 import b64decode
from collections import namedtuple
from enum import Enum
from http import HTTPStatus
from os.path import exists
from ssl import DER_cert_to_PEM_cert
from subprocess import PIPE, Popen
from typing import TYPE_CHECKING, Any
from urllib.parse import quote, urlparse

import requests
from defusedxml import ElementTree
from keycloak import KeycloakAdmin, KeycloakPostError
from keycloak.exceptions import KeycloakError, KeycloakGetError, raise_error_from_response
from keycloak.urls_patterns import URL_ADMIN_COMPONENTS, URL_ADMIN_REALM
from ldap.dn import explode_rdn
from ldap.filter import filter_format


warnings.filterwarnings("ignore", category=DeprecationWarning)

if TYPE_CHECKING:
    from univention.udm.modules.settings_data import SettingsDataObject


try:
    from univention.appcenter.app import LooseVersion
    from univention.appcenter.app_cache import Apps
    from univention.config_registry import ucr
    from univention.udm import UDM
    from univention.udm.binary_props import Base64Bzip2BinaryProperty
    IS_UCS = True
except ImportError:
    ucr = {}
    IS_UCS = False

DEFAULT_REALM = "master"
URL_ADMIN_REQUIRED_ACTIONS = URL_ADMIN_REALM + "/authentication/required-actions"
URL_ADMIN_REQUIRED_ACTION_REGISTER = URL_ADMIN_REALM + "/authentication/register-required-action"
DEFAULT_EXTENTIONS = ["password", "ldapmapper", "self-service"]
DEFAULT_USER_STORAGE_PROVIDER_NAME = "ldap-provider"
DEFAULT_USER_STORAGE_PROVIDER_ID = "ldap"
KERBEROS_KEYTAB_PATH = "/var/lib/univention-appcenter/apps/keycloak/conf/keycloak.keytab"
LEGACY_APP_AUTHORIZATION_NAME = "flow with legacy app authorization"
LOGIN_LINKS = 12

# default LDAP attribute mapper
#  * sn -> lastName
#  * givenName -> firstName
#  * mailPrimaryAddress -> email
#  * uid -> uid
#  * uid -> username
#  * modifyTimestamp -> modifyTimestamp
#  * createTimestamp -> createTimestamp
#  * displayName -> displayName
#  * entryUUID -> entryUUID

DEFAULT_ICS_MAPPERS = [
    {
        "protocol": "openid-connect",
        "protocolMapper": "oidc-usermodel-attribute-mapper",
        "name": "phoenixusername_temp",
        "consentRequired": False,
        "config": {
            "introspection.token.claim": "true",
            "userinfo.token.claim": "true",
            "user.attribute": "uid",
            "id.token.claim": "true",
            "access.token.claim": "true",
            "claim.name": "phoenixusername",
            "jsonType.label": "String",
            "lightweight.claim": False,
            "multivalued": False,
            "aggregate.attrs": False,
        },
    },
    {
        "protocol": "openid-connect",
        "protocolMapper": "oidc-audience-mapper",
        "name": "intercom-audience",
        "consentRequired": False,
        "config": {
            "included.client.audience": "opendesk-intercom",
            "id.token.claim": "false",
            "access.token.claim": "true",
            "introspection.token.claim": "true",
            "userinfo.token.claim": "false",
            "included.custom.audience": "",
            "lightweight.claim": False,
        },
    },
    {
        "protocol": "openid-connect",
        "protocolMapper": "oidc-usermodel-attribute-mapper",
        "name": "entryuuid_temp",
        "consentRequired": False,
        "config": {
            "introspection.token.claim": "true",
            "userinfo.token.claim": "true",
            "user.attribute": "entryUUID",
            "id.token.claim": "true",
            "access.token.claim": "true",
            "claim.name": "entryuuid",
            "jsonType.label": "String",
            "lightweight.claim": False,
            "multivalued": False,
            "aggregate.attrs": False,
        },
    },
]
DEFAULT_GUARDIAN_MANAGEMENT_MAPPERS = [
    {
        "protocol": "openid-connect",
        "protocolMapper": "oidc-usersessionmodel-note-mapper",
        "name": "Client IP Address",
        "consentRequired": False,
        "config": {
            "user.session.note": "clientAddress",
            "claim.name": "clientAddress",
            "jsonType.label": "String",
            "id.token.claim": "true",
            "access.token.claim": "true",
            "userinfo.token.claim": "true",
            "access.tokenResponse.claim": False,
        },
    },
    {
        "protocol": "openid-connect",
        "protocolMapper": "oidc-usersessionmodel-note-mapper",
        "name": "Client ID",
        "consentRequired": False,
        "config": {
            "user.session.note": "client_id",
            "claim.name": "client_id",
            "jsonType.label": "String",
            "id.token.claim": "true",
            "access.token.claim": "true",
            "userinfo.token.claim": "true",
            "access.tokenResponse.claim": False,
        },
    },
    {
        "protocol": "openid-connect",
        "protocolMapper": "oidc-audience-mapper",
        "name": "audiencemap",
        "consentRequired": False,
        "config": {
            "included.client.audience": "guardian-cli",
            "included.custom.audience": "",
            "id.token.claim": "true",
            "access.token.claim": "true",
            "userinfo.token.claim": "true",
        },
    },
    {
        "protocol": "openid-connect",
        "protocolMapper": "oidc-usersessionmodel-note-mapper",
        "name": "Client Host",
        "consentRequired": False,
        "config": {
            "user.session.note": "clientHost",
            "claim.name": "clientHost",
            "jsonType.label": "String",
            "id.token.claim": "true",
            "access.token.claim": "true",
            "userinfo.token.claim": "true",
            "access.tokenResponse.claim": False,
        },
    },
    {
        "protocol": "openid-connect",
        "protocolMapper": "oidc-usermodel-attribute-mapper",
        "name": "dn",
        "consentRequired": False,
        "config": {
            "user.attribute": "LDAP_ENTRY_DN",
            "claim.name": "dn",
            "jsonType.label": "String",
            "id.token.claim": "false",
            "access.token.claim": "true",
            "userinfo.token.claim": "false",
            "multivalued": False,
            "aggregate.attrs": False,
        },
    },
    {
        "protocol": "openid-connect",
        "protocolMapper": "oidc-audience-mapper",
        "name": "guardian-audience",
        "consentRequired": False,
        "config": {
            "included.client.audience": "guardian",
            "included.custom.audience": "",
            "id.token.claim": "false",
            "access.token.claim": "true",
            "userinfo.token.claim": "false",
        },
    },
]


# create a keyvalue class
class ArgparseKeyvalueAction(Action):
    # Constructor calling
    def __call__(self, parser: ArgumentParser, namespace: Namespace, values: list, option_string: str) -> None:
        setattr(namespace, self.dest, {})
        if not values:
            parser.error(f"argument --{self.dest}: expected one argument")
        for value in values:
            if '=' not in value:
                parser.error(f"argument --{self.dest}: expected argument as key=value")
            # split it into key and value
            key, value = value.split('=', 1)
            # assign into dictionary
            getattr(namespace, self.dest)[key] = value


class KeycloakDomainConfig:

    def __init__(
        self,
        binddn: str | None,
        bindpwd: str | None,
    ) -> None:
        if binddn and bindpwd:
            server = ucr["ldap/master"]
            server_port = ucr["ldap/master/port"]
            udm = UDM.credentials(binddn, bindpwd, server=server, port=server_port).version(3)
        else:
            udm = UDM.admin().version(3)
        self.name = "keycloak"
        self.mod = udm.get("settings/data")
        self.position = f"cn=data,cn=univention,{ucr.get('ldap/base')}"
        apps_cache = Apps()
        installed_apps = apps_cache.get_all_locally_installed_apps()
        keycloak_version = [app.version for app in installed_apps if app.id == "keycloak"]
        if not keycloak_version:
            raise Exception("This command needs to be executed on a UCS system where keycloak is installed")
        self.current_version = keycloak_version[0]
        keycloak_apps = apps_cache.get_all_apps_with_id("keycloak")
        self.keycloak_versions = [app.version for app in keycloak_apps]
        self.keycloak_versions.sort(key=LooseVersion)

    def get_obj(self) -> SettingsDataObject:
        obj = self.mod.get(f'cn={self.name},{self.position}')
        return obj

    def save_obj(self, data: dict, obj: SettingsDataObject) -> None:
        raw_value = json.dumps(data).encode("ascii")
        obj.props.data = Base64Bzip2BinaryProperty("data", raw_value=raw_value)
        obj.save()

    def get(self) -> dict:
        obj = self.get_obj()
        return json.loads(obj.props.data.raw)

    def get_domain_config_version(self) -> str:
        data = self.get()
        return data.get("domain_config_version")

    def get_domain_config_init(self) -> str:
        data = self.get()
        return data.get("domain_config_init")

    def set(self, values):
        obj = self.get_obj()
        data = self.get()
        for val in values:
            key, value = val.split("=", 1)
            data[key] = value
        self.save_obj(data, obj)

    def set_domain_config_version(self, version: str) -> None:
        if version not in self.keycloak_versions:
            raise Exception(f"{version} is not a valid keycloak version")
        obj = self.get_obj()
        data = json.loads(obj.props.data.raw)
        data["domain_config_version"] = version
        self.save_obj(data, obj)

    def set_domain_config_init(self, version: str) -> None:
        if version not in self.keycloak_versions:
            raise Exception(f"{version} is not a valid keycloak version")
        obj = self.get_obj()
        data = json.loads(obj.props.data.raw)
        data["domain_config_init"] = version
        self.save_obj(data, obj)


class UniventionKeycloakAdmin(KeycloakAdmin):

    def __init__(self, opt: Namespace, realm=None) -> None:
        KeycloakAdmin.__init__(
            self,
            server_url=opt.keycloak_url,
            username=opt.binduser,
            password=opt.bindpwd,
            realm_name=realm or opt.realm,
            user_realm_name=DEFAULT_REALM,
            verify=opt.no_ssl_verify,
        )

    def get_user_storage_provider_id(self, name: str | None = None) -> str:
        """Get the id for user storage provider"""
        return self.get_user_storage_provider(name=name)["id"]

    def get_user_storage_provider(self, name: str | None = None) -> dict:
        if name is None:
            name = DEFAULT_USER_STORAGE_PROVIDER_NAME
        result = self.get_components(query={"name": name, "type": "org.keycloak.storage.UserStorageProvider"})
        if not result or len(result) != 1:
            raise Exception(f"user storage provider {name} not found")
        return result[0]

    def get_user_storage_ldap_mappers(self, parent_id: str | None = None) -> list:
        """Get all the org.keycloak.storage.ldap.mappers.LDAPStorageMapper objects from a given component"""
        if parent_id is None:
            parent_id = self.get_user_storage_provider_id()
        mappers = self.get_components(query={"parent": parent_id, "type": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper"})
        return mappers

    def get_mapper(self, client_id: str, mapper_type: str) -> list:
        """Get saml-user-attribute-mapper mapper for given client"""
        _id = self.get_client_id(client_id)
        client = self.get_client(_id)
        mappers = [
            x for x in client.get("protocolMappers", [])
            if x["protocolMapper"] == mapper_type
        ]
        return mappers

    def create_mapper(self, client_id: str, payload: dict) -> None:
        """Create mapper for given client"""
        _id = self.get_client_id(client_id)
        try:
            self.add_mapper_to_client(_id, payload)
        except KeycloakPostError as exc:
            # ignore conflict "Protocol mapper exists with same name"
            if exc.response_code != HTTPStatus.CONFLICT:
                raise

    def delete_mapper(self, client_id: str, name: str) -> None:
        """Delete mapper for given client"""
        _id = self.get_client_id(client_id)
        client = self.get_client(_id)
        for mapper in client.get("protocolMappers"):
            if mapper["name"] == name:
                url = f"admin/realms/{quote(self.realm_name, safe='')}/clients/{quote(_id, safe='')}/protocol-mappers/models/{quote(mapper['id'], safe='')}"
                self.raw_delete(url)
                break
        else:
            print(f"No mapper found with name {name}.")

    def get_authentication_flow_id(self, flow_name):
        flows = self.get_authentication_flows()

        for flow in flows:
            if flow_name == flow.get('alias'):
                return flow["id"]

        return None

    def create_and_update_authentication_flow_subflow(self, payload: dict, flow_alias: str) -> None:
        _payload = {
            "alias": payload["displayName"],
            "type": payload["type"],
        }
        self.create_authentication_flow_subflow(payload=_payload, flow_alias=flow_alias)
        executions = self.get_authentication_flow_executions(flow_alias=flow_alias)
        flow = [x for x in executions if x.get("displayName") == payload["displayName"]][0]  # noqa: RUF015
        payload["id"] = flow["id"]
        del payload["type"]
        try:
            self.update_authentication_flow_executions(payload=payload, flow_alias=flow_alias)
        except KeycloakGetError as exc:
            if exc.response_code != 202:
                raise

    def create_and_update_authentication_flow_execution(self, payload: dict, flow_alias: str) -> None:
        _payload = {"provider": payload.get("providerId")}
        self.create_authentication_flow_execution(payload=_payload, flow_alias=flow_alias)
        executions = self.get_authentication_flow_executions(flow_alias=flow_alias)
        execution = [x for x in executions if x.get("providerId") == payload.get("providerId")][0]  # noqa: RUF015
        payload["id"] = execution["id"]
        try:
            self.update_authentication_flow_executions(payload=payload, flow_alias=flow_alias)
        except KeycloakGetError as exc:
            if exc.response_code != 202:
                raise

    def get_message_bundles(self, language: str) -> dict:
        res = self.raw_get(f"admin/realms/{self.realm_name}/localization/{language}")
        raise_error_from_response(res, KeycloakGetError, expected_codes=[200])
        return res.json()

    def create_message_bundle(self, language: str, key: str, value: str) -> None:
        # overwrite default content-type
        content_type = self._connection.headers["Content-Type"]
        try:
            self._connection.headers["Content-Type"] = "text/plain"
            res = self.raw_put(f"admin/realms/{self.realm_name}/localization/{language}/{key}", data=value)
            raise_error_from_response(res, KeycloakError, expected_codes=[204])
        finally:
            self._connection.headers["Content-Type"] = content_type

    def delete_message_bundle(self, language: str, key: str) -> None:
        res = self.raw_delete(f"admin/realms/{self.realm_name}/localization/{language}/{key}")
        raise_error_from_response(res, KeycloakError, expected_codes=[204, 404])


class ExtType(Enum):
    ACTION = 1
    LDAP_MAPPER = 2


Ext = namedtuple("Ext", ["type", "alias", "name"])


class ExtService:

    def __init__(self, kc_admin: KeycloakAdmin):
        self._kc_admin = kc_admin

    @classmethod
    def extensions(cls) -> dict[str, Ext]:
        return {
            "password": Ext(ExtType.ACTION, "UNIVENTION_UPDATE_PASSWORD", "Univention update password"),
            "self-service": Ext(ExtType.ACTION, "UNIVENTION_SELF_SERVICE", "Univention self service checks"),
            "ldapmapper": Ext(ExtType.LDAP_MAPPER, "univention-ldap-mapper", "Univention ldap mapper"),
        }

    def register_required_action(self, realm: str, alias: str, name: str):
        action = self._get_required_action_by_alias(realm, alias)
        if not action:
            self._create_required_action(realm, alias, name)

    def unregister_required_action(self, realm: str, alias: str):
        action = self._get_required_action_by_alias(realm, alias)
        if action:
            self._delete_required_action(realm, alias)

    def register_ldap_mapper(self, realm: str, alias: str, name: str):
        params = {"providerId": alias, "name": name}
        mapper = self._kc_admin.get_components(params)
        if not mapper:
            params = {"providerId": DEFAULT_USER_STORAGE_PROVIDER_ID, "name": DEFAULT_USER_STORAGE_PROVIDER_NAME}
            parent = self._realm_components(realm, params)
            if not parent:
                raise RuntimeError("Ldap provider with name " + DEFAULT_USER_STORAGE_PROVIDER_NAME + "is not found.")

            payload = {
                "name": name,
                "parentId": parent[0]["id"],
                "config": {},
                "providerId": alias,
                "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
            }
            self._kc_admin.create_component(payload=payload)

    def unregister_ldap_mapper(self, realm: str, alias: str):
        params = {"providerId": alias, "name": "Univention ldap mapper"}
        mapper = self._realm_components(realm, params)
        if mapper:
            self._kc_admin.delete_component(mapper[0]["id"])

    def _realm_components(self, realm: str, query: dict = {}):
        params_path = {"realm-name": realm}
        data_raw = self._kc_admin.raw_get(URL_ADMIN_COMPONENTS.format(**params_path), data=None, **query)

        return raise_error_from_response(data_raw, KeycloakGetError)

    def _create_required_action(self, realm: str, provider_id: str, name: str, enabled: bool = True):
        params = {"realm-name": realm}
        payload = {"providerId": provider_id, "name": name, "enabled": enabled}

        data_raw = self._kc_admin.raw_post(URL_ADMIN_REQUIRED_ACTION_REGISTER.format(**params), data=json.dumps(payload))
        return raise_error_from_response(data_raw, KeycloakError)

    def _get_required_actions(self, realm: str):
        params_path = {"realm-name": realm}
        data_raw = self._kc_admin.raw_get(URL_ADMIN_REQUIRED_ACTIONS.format(**params_path))

        return raise_error_from_response(data_raw, KeycloakGetError)

    def _get_required_action_by_alias(self, realm: str, action_alias: str):
        actions = self._get_required_actions(realm)
        for a in actions:
            if a["alias"] == action_alias:
                return a

        return None

    def _delete_required_action(self, realm: str, alias: str):
        params = {"realm-name": realm}
        url = URL_ADMIN_REQUIRED_ACTIONS.format(**params) + f"/{alias}"

        data_raw = self._kc_admin.raw_delete(url)
        return raise_error_from_response(data_raw, KeycloakGetError)


def check_and_create_component(kc_client, name, prov_id, payload_, force=False):
    ldap_component_filter = {'name': name, 'providerId': prov_id}
    ldap_component_list = kc_client.get_components(ldap_component_filter)
    if not ldap_component_list:
        kc_client.create_component(payload=payload_)
        ldap_component_list = kc_client.get_components(ldap_component_filter)
    elif force:
        ldap_component = ldap_component_list.pop()
        kc_client.delete_component(ldap_component.get("id"))
        kc_client.create_component(payload=payload_)
        ldap_component_list = kc_client.get_components(ldap_component_filter)

    ldap_component = ldap_component_list.pop()
    return ldap_component.get("id")


def modify_component(kc_client, payload_):
    name = payload_["name"]
    prov_id = payload_['providerId']
    ldap_component_filter = {'name': name, 'providerId': prov_id, 'parentId': payload_['parentId']}
    ldap_component_list = kc_client.get_components(ldap_component_filter)
    if ldap_component_list:
        ldap_component = ldap_component_list.pop()
        payload = ldap_component
        payload["config"].update(payload_["config"])
        kc_client.update_component(ldap_component.get("id"), payload=payload)
        ldap_component_list = kc_client.get_components(ldap_component_filter)

    ldap_component = ldap_component_list.pop()
    return ldap_component.get("id")


def get_realm_id(kc_client, name):
    ldap_realms_list = kc_client.get_realms()
    realm_info = [realm for realm in ldap_realms_list if realm['realm'] == name]
    return realm_info[0]["id"]


def update_saml_metadata_from_xml(opt, metadata_url, payload, metadata_file, no_ssl_verify, umc_uid_mapper=False):
    # TODO: just upload the XML to keycloak instead of parsing it here
    if not metadata_file:
        xml_content = requests.get(metadata_url, verify=no_ssl_verify).content
    else:
        with open(metadata_file) as fd:
            xml_content = fd.read().strip()
    try:
        saml_descriptor_xml = ElementTree.fromstring(xml_content)
    except ElementTree.ParseError as exc:
        print("ERROR: Could not parse XML: %s Content:\n%s" % (exc, xml_content), file=sys.stderr)
        sys.exit(1)

    attribs = payload.setdefault('attributes', {})
    valid_redirect_urls = payload.setdefault("redirectUris", [])
    logout_post = [x.attrib["Location"] for x in saml_descriptor_xml.findall('.//{urn:oasis:names:tc:SAML:2.0:metadata}SingleLogoutService') if x.attrib.get('Binding') == 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST']
    if logout_post:
        attribs["saml_single_logout_service_url_post"] = logout_post[0]  # keycloak can only handle one url
        valid_redirect_urls.extend(logout_post)
    logout_redirect = [x.attrib["Location"] for x in saml_descriptor_xml.findall('.//{urn:oasis:names:tc:SAML:2.0:metadata}SingleLogoutService') if x.attrib.get('Binding') == 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']
    if logout_redirect:
        attribs["saml_single_logout_service_url_redirect"] = logout_redirect[0]  # keycloak can only handle one url
        valid_redirect_urls.extend(logout_redirect)
    attribs["post.logout.redirect.uris"] = '+'

    acs_endpoints = [x.attrib for x in saml_descriptor_xml.findall('.//{urn:oasis:names:tc:SAML:2.0:metadata}AssertionConsumerService') if x.attrib.get('Binding') == 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST']
    if acs_endpoints:
        attribs["saml_assertion_consumer_url_post"] = sorted(acs_endpoints, key=lambda x: int(x.get('index', '-1')))[0]['Location']
        valid_redirect_urls.extend(x['Location'] for x in acs_endpoints)
    acs_endpoints = [x.attrib for x in saml_descriptor_xml.findall('.//{urn:oasis:names:tc:SAML:2.0:metadata}AssertionConsumerService') if x.attrib.get('Binding') == 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']
    if acs_endpoints:
        attribs["saml_assertion_consumer_url_redirect"] = sorted(acs_endpoints, key=lambda x: int(x.get('index', '-1')))[0]['Location']
        valid_redirect_urls.extend(x['Location'] for x in acs_endpoints)

    name_id_formats = {
        'urn:oasis:names:tc:SAML:2.0:nameid-format:transient': 'transient',
        'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent': 'persistent',
        'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress': 'email',
        'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified': 'username',
        'urn:mace:shibboleth:1.0:nameIdentifier': 'username',
    }

    name_id_format = [
        name_id_formats[x.text]
        for x in saml_descriptor_xml.findall('.//{urn:oasis:names:tc:SAML:2.0:metadata}NameIDFormat')
        if x.text in name_id_formats
    ]

    if name_id_format:
        attribs["saml_name_id_format"] = opt.name_id_format if getattr(opt, 'name_id_format', None) in name_id_format else name_id_format[0]  # keycloak can only handle one NameID Format
        attribs["saml_force_name_id_format"] = "true"

    name = saml_descriptor_xml.find('.//{urn:oasis:names:tc:SAML:2.0:metadata}ServiceName')
    if not payload.get("name"):
        if name is not None and name.text:
            payload["name"] = name.text

    description = saml_descriptor_xml.find('.//{urn:oasis:names:tc:SAML:2.0:metadata}ServiceDescription')
    if not payload.get("description"):
        if description is not None and description.text:
            payload["description"] = description.text

    name_formats = {
        'urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified': 'Unspecified',
        'urn:oasis:names:tc:SAML:2.0:attrname-format:uri': 'URI Reference',
        'urn:oasis:names:tc:SAML:2.0:attrname-format:basic': 'Basic',
        # 'urn:oasis:names:tc:SAML:2.0:attrname-format:emailAddress': '',  # doesn't exist in keycloak
    }
    protocol_mappers = payload.setdefault("protocolMappers", [])
    for ldap_attribute in saml_descriptor_xml.findall('.//{urn:oasis:names:tc:SAML:2.0:metadata}RequestedAttribute'):
        if ldap_attribute.attrib.get('isRequired') != 'true' and 'FriendlyName' not in ldap_attribute.attrib:
            continue
        if umc_uid_mapper and ldap_attribute.attrib["Name"] == "urn:oid:0.9.2342.19200300.100.1.1":
            continue
        friendly_name = ldap_attribute.attrib['FriendlyName']
        mapper = {
            "name": f"{friendly_name}_mapper",
            "protocol": "saml",
            "protocolMapper": "saml-user-attribute-mapper",
            "consentRequired": False,
            "config": {
                "attribute.name": ldap_attribute.attrib['Name'],
                "attribute.nameformat": name_formats[ldap_attribute.attrib['NameFormat']],
                "friendly.name": friendly_name,
                "user.attribute": friendly_name,
            },
        }
        if not _get_mapper(mapper["name"], protocol_mappers):
            protocol_mappers.append(mapper)


def _get_mapper(name, mappers):
    """find specific mapper in list of mappers"""
    for mapper in mappers:
        if mapper.get("name") == name:
            return mapper


def get_realms(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    realms = session.get_realms()
    if not opt.all:
        realms = [x["realm"] for x in realms]
    _print_json(opt.json, realms)


def _get_proxy_realm(session: UniventionKeycloakAdmin) -> list:
    """proxy realms are realms with exactly one IDP called ucs-oidc"""
    realms = [
        x for x in session.get_realms()
        if x.get("identityProviders")
        if len(x["identityProviders"]) == 1
        if x["identityProviders"][0].get("alias") == "ucs-oidc"
    ]
    return realms


def get_proxy_realm(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    realms = _get_proxy_realm(session)
    if not opt.all:
        realms = [{"id": x["realm"]} for x in realms]
    if opt.certificate:
        for realm in realms:
            url = f"{session.server_url}/realms/{realm['id']}/protocol/saml/descriptor"
            descriptor = requests.get(url)
            xml = ElementTree.fromstring(descriptor.content)
            cert = xml.find('.//{http://www.w3.org/2000/09/xmldsig#}X509Certificate').text
            realm["certificate"] = cert
    _print_json(opt.json, realms)


def _create_proxy_realm_oidc_client(session: UniventionKeycloakAdmin) -> str:
    name = "proxy-realm-client"
    payload = {
        "clientId": name,
        "name": "",
        "description": "",
        "rootUrl": "",
        "adminUrl": "",
        "baseUrl": "",
        "enabled": True,
        "alwaysDisplayInConsole": False,
        "clientAuthenticatorType": "client-secret",
        "redirectUris": [
            "https://*",
        ],
        "webOrigins": [
            "",
        ],
        "notBefore": 0,
        "bearerOnly": False,
        "consentRequired": False,
        "standardFlowEnabled": True,
        "implicitFlowEnabled": False,
        "directAccessGrantsEnabled": False,
        "serviceAccountsEnabled": False,
        "publicClient": False,
        "frontchannelLogout": True,
        "protocol": "openid-connect",
        "fullScopeAllowed": True,
        "protocolMappers": [
            {
                "name": "uid",
                "protocol": "openid-connect",
                "protocolMapper": "oidc-usermodel-attribute-mapper",
                "consentRequired": False,
                "config": {
                    "userinfo.token.claim": "true",
                    "user.attribute": "uid",
                    "id.token.claim": "true",
                    "access.token.claim": "true",
                    "claim.name": "uid",
                    "jsonType.label": "String",
                },
            },
            {
                "name": "entryUUID",
                "protocol": "openid-connect",
                "protocolMapper": "oidc-usermodel-attribute-mapper",
                "consentRequired": False,
                "config": {
                    "userinfo.token.claim": "true",
                    "user.attribute": "entryUUID",
                    "id.token.claim": "true",
                    "access.token.claim": "true",
                    "claim.name": "entryUUID",
                    "jsonType.label": "String",
                },
            },
        ],
    }
    session.create_client(payload=payload, skip_exists=True)
    secret = next(x["secret"] for x in session.get_clients() if x.get("clientId") == name)
    return secret


def _create_proxy_realm(session: UniventionKeycloakAdmin, name: str, secret: str) -> str:
    default_realm = f"{session.server_url}realms/{session.realm_name}"
    payload = {
        "id": name,
        "realm": name,
        "enabled": True,
        "internationalizationEnabled": True,
        "adminTheme": "keycloak",
        "accountTheme": "keycloak",
        "emailTheme": "keycloak",
        "loginTheme": "UCS",
        "browserSecurityHeaders": {
            "contentSecurityPolicyReportOnly": "",
            "xContentTypeOptions": "nosniff",
            "xRobotsTag": "none",
            "xFrameOptions": "",  # we want to overwrite default value, which is: SAMEORIGIN
            "xXSSProtection": "1; mode=block",
            "strictTransportSecurity": "max-age=31536000; includeSubDomains",
        },
        "identityProviders": [
            {
                "alias": "ucs-oidc",
                "displayName": "",
                "providerId": "oidc",
                "enabled": True,
                "updateProfileFirstLoginMode": "on",
                "trustEmail": False,
                "storeToken": False,
                "addReadTokenRoleOnCreate": False,
                "authenticateByDefault": False,
                "linkOnly": False,
                "config": {
                    "userInfoUrl": f"{default_realm}/protocol/openid-connect/userinfo",
                    "validateSignature": "true",
                    "tokenUrl": f"{default_realm}/protocol/openid-connect/token",
                    "clientId": "proxy-realm-client",
                    "jwksUrl": f"{default_realm}/protocol/openid-connect/certs",
                    "issuer": f"{default_realm}",
                    "useJwksUrl": "true",
                    "pkceEnabled": "false",
                    "authorizationUrl": f"{default_realm}/protocol/openid-connect/auth",
                    "clientAuthMethod": "client_secret_post",
                    "logoutUrl": f"{default_realm}/protocol/openid-connect/logout",
                    "clientSecret": secret,
                    "syncMode": "FORCE",
                },
            },
        ],
        "identityProviderMappers": [
            {
                "name": "uid",
                "identityProviderAlias": "ucs-oidc",
                "identityProviderMapper": "oidc-user-attribute-idp-mapper",
                "config": {
                    "syncMode": "FORCE",
                    "claim": "uid",
                    "user.attribute": "uid",
                },
            },
            {
                "name": "entryUUID",
                "identityProviderAlias": "ucs-oidc",
                "identityProviderMapper": "oidc-user-attribute-idp-mapper",
                "config": {
                    "syncMode": "FORCE",
                    "claim": "entryUUID",
                    "user.attribute": "entryUUID",
                },
            },
        ],
    }
    session.create_realm(payload=payload, skip_exists=True)


def _create_proxy_realm_azure_client(session: UniventionKeycloakAdmin) -> None:
    payload = {
        "clientId": "urn:federation:MicrosoftOnline",
        "surrogateAuthRequired": False,
        "enabled": True,
        "alwaysDisplayInConsole": False,
        "redirectUris": [
            "https://login.microsoftonline.com/login.srf",
        ],
        "webOrigins": [],
        "notBefore": 0,
        "bearerOnly": False,
        "consentRequired": False,
        "standardFlowEnabled": True,
        "implicitFlowEnabled": False,
        "directAccessGrantsEnabled": False,
        "serviceAccountsEnabled": False,
        "publicClient": True,
        "frontchannelLogout": True,
        "protocol": "saml",
        "attributes": {
            "use.refresh.tokens": "true",
            "oidc.ciba.grant.enabled": "false",
            "backchannel.logout.session.required": "true",
            "client_credentials.use_refresh_token": "false",
            "saml.signature.algorithm": "RSA_SHA256",
            "saml.client.signature": "false",
            "require.pushed.authorization.requests": "false",
            "saml.allow.ecp.flow": "false",
            "id.token.as.detached.signature": "false",
            "saml.assertion.signature": "true",
            "saml_single_logout_service_url_post": "https://login.microsoftonline.com/login.srf",
            "saml.encrypt": "false",
            "saml_assertion_consumer_url_post": "https://login.microsoftonline.com/login.srf",
            "saml.server.signature": "true",
            "saml_idp_initiated_sso_url_name": "MicrosoftOnline",
            "saml.artifact.binding": "false",
            "saml_single_logout_service_url_redirect": "https://login.microsoftonline.com/login.srf",
            "saml_force_name_id_format": "false",
            "saml_name_id_format": "persistent",
        },
        "fullScopeAllowed": True,
        "protocolMappers": [
            {
                "name": "entryUUID",
                "protocol": "saml",
                "protocolMapper": "univention-saml-user-attribute-nameid-mapper-base64",
                "consentRequired": False,
                "config": {
                    "user.attribute": "entryUUID",
                    "mapper.nameid.format": "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
                },
            },
            {
                "name": "userid_mapper",
                "protocol": "saml",
                "protocolMapper": "saml-user-attribute-mapper",
                "consentRequired": False,
                "config": {
                    "attribute.nameformat": "Basic",
                    "user.attribute": "uid",
                    "friendly.name": "uid",
                    "attribute.name": "uid",
                },
            },
        ],
    }
    session.create_client(payload=payload, skip_exists=True)


def __create_proxy_realm_update_flows(session: UniventionKeycloakAdmin) -> None:
    # set default IDP in browser flow
    redirector_execution_id = next(
        x["id"] for x in session.get_authentication_flow_executions("browser")
        if x["displayName"] == "Identity Provider Redirector"
    )
    payload = {"alias": "ucs-oidc", "config": {"defaultProvider": "ucs-oidc"}}
    url = f"admin/realms/{session.realm_name}/authentication/executions/{redirector_execution_id}/config"
    resp = session.raw_post(url, json.dumps(payload))
    raise_error_from_response(resp, KeycloakGetError)
    # disable profile review for first broker login
    review_profile_id = next(
        x["id"] for x in session.get_authentication_flow_executions("first broker login")
        if x["displayName"] == "Review Profile"
    )
    payload = {"alias": "review profile config", "config": {"update.profile.on.first.login": "off"}}
    url = f"admin/realms/{session.realm_name}/authentication/executions/{review_profile_id}/config"
    resp = session.raw_post(url, json.dumps(payload))
    raise_error_from_response(resp, KeycloakGetError)


def create_proxy_realm(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)

    if session.realm_name == opt.name:
        print(f"ERROR: can not create a proxy realm with the same name \"{opt.name}\" as the default realm", file=sys.stderr)
        sys.exit(1)

    # verify oidc client in default realm
    client_secret = _create_proxy_realm_oidc_client(session)

    # create realm
    _create_proxy_realm(session, opt.name, client_secret)

    # switch to realm
    session.realm_name = opt.name

    # update auth flows
    __create_proxy_realm_update_flows(session)

    # create azure client
    _create_proxy_realm_azure_client(session)


def update_proxy_realm(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    if session.realm_name == opt.name:
        print(f"ERROR: can not update proxy realm with the same name \"{opt.name}\" as the default realm", file=sys.stderr)
        sys.exit(1)
    realms = _get_proxy_realm(session)
    if opt.name not in [x["realm"] for x in realms]:
        print(f"ERROR: \"{opt.name}\" not a proxy realm")
        sys.exit(1)
    payload = json.loads(opt.changes)
    session.update_realm(opt.name, payload)


def delete_proxy_realm(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    if session.realm_name == opt.name:
        print(f"ERROR: can not update proxy realm with the same name \"{opt.name}\" as the default realm", file=sys.stderr)
        sys.exit(1)
    realms = _get_proxy_realm(session)
    if opt.name not in [x["realm"] for x in realms]:
        print(f"ERROR: \"{opt.name}\" not a proxy realm")
        sys.exit(1)
    session.delete_realm(opt.name)


def get_oidc_clients(opt: Namespace) -> None:
    get_clients(opt, protocol="openid-connect")


def get_saml_clients(opt: Namespace) -> None:
    get_clients(opt, protocol="saml")


def get_clients(opt: Namespace, protocol: str | None = "openid-connect") -> None:
    session = UniventionKeycloakAdmin(opt)
    clients = [client for client in session.get_clients() if client["protocol"] == protocol]
    if opt.client_id:
        clients = [client for client in clients if client["clientId"] == opt.client_id]
    if not opt.all:
        clients = [x["clientId"] for x in clients]
    _print_json(opt.json, clients)


def update_client(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    clientid = session.get_client_id(opt.clientid)
    payload = json.loads(opt.changes)
    if opt.metadata_file:
        update_saml_metadata_from_xml(opt, clientid, payload, opt.metadata_file, opt.no_ssl_verify)
    session.update_client(clientid, payload)


def remove_client(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    clientid = session.get_client_id(opt.clientid)
    session.delete_client(clientid)


def create_or_update_client(kc_admin: KeycloakAdmin, payload: dict[str, Any], client_id: str, force: bool):
    try:
        kc_admin.create_client(payload=payload)
    except KeycloakPostError as exc:
        if exc.response_code != HTTPStatus.CONFLICT:
            raise

        if force:
            kc_client_id = kc_admin.get_client_id(client_id)
            if kc_client_id is None:
                raise RuntimeError(f'client with name {client_id} and client_id {kc_client_id} not found') from None
            kc_admin.update_client(client_id=kc_client_id, payload=payload)


def create_SAML_client(opt):
    print("CREATING KEYCLOAK SAML CLIENT.....")
    if opt.metadata_url:
        client_id = opt.metadata_url
    elif opt.client_id:
        client_id = opt.client_id
    else:
        raise NotImplementedError()

    umc_uid_mapper = {
        "name": "userid_mapper",
        "protocol": "saml",
        "protocolMapper": "saml-user-attribute-mapper",
        "consentRequired": False,
        "config": {
            "attribute.name": "urn:oid:0.9.2342.19200300.100.1.1",
            "attribute.nameformat": "URI Reference",
            "friendly.name": "uid",
            "user.attribute": "uid",
        },
    }
    client_payload_saml = {
        "clientId": client_id,
        "name": opt.name,
        "surrogateAuthRequired": False,
        "enabled": not opt.not_enabled,
        "alwaysDisplayInConsole": False,
        "clientAuthenticatorType": "client-secret",
        "redirectUris": opt.valid_redirect_uris,
        "webOrigins": [],
        "description": opt.description,
        "notBefore": 0,
        "bearerOnly": False,
        "consentRequired": False,
        "standardFlowEnabled": True,
        "implicitFlowEnabled": False,
        "directAccessGrantsEnabled": True,
        "serviceAccountsEnabled": False,
        "publicClient": True,
        "frontchannelLogout": not opt.frontchannel_logout_off,
        "protocol": "saml",
        "protocolMappers": [],
        "attributes": {
            "saml_name_id_format": opt.name_id_format,
            "saml.multivalued.roles": "false",
            "saml.force.post.binding": "true",
            "oauth2.device.authorization.grant.enabled": "false",
            "backchannel.logout.revoke.offline.tokens": "false",
            "saml.server.signature.keyinfo.ext": "false",
            "use.refresh.tokens": "true",
            "oidc.ciba.grant.enabled": "false",
            "backchannel.logout.session.required": "true",
            "client_credentials.use_refresh_token": "false",
            "saml.signature.algorithm": "RSA_SHA256",
            "saml.client.signature": "false",
            "require.pushed.authorization.requests": "false",
            "id.token.as.detached.signature": "false",
            "saml.assertion.signature": "true",
            "saml_single_logout_service_url_post": opt.single_logout_service_url_post if opt.single_logout_service_url_post is not None else "",
            "saml.encrypt": "false",
            "saml_assertion_consumer_url_post": opt.assertion_consumer_url_post if opt.assertion_consumer_url_post is not None else "",
            "saml.server.signature": "true",
            "exclude.session.state.from.auth.response": "false",
            "saml.artifact.binding": "false",
            "saml_single_logout_service_url_redirect": opt.single_logout_service_url_redirect if opt.single_logout_service_url_redirect is not None else "",
            "saml_force_name_id_format": "false",
            "tls.client.certificate.bound.access.tokens": "false",
            "acr.loa.map": "{}",
            "saml.authnstatement": "true",
            "display.on.consent.screen": "false",
            "saml.assertion.lifespan": "300",
            "token.response.type.bearer.lower-case": "false",
            "saml.onetimeuse.condition": "false",
            "saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#",
            "policyUri": opt.policy_url,
        },
        "authenticationFlowBindingOverrides": {},
        "fullScopeAllowed": True,
        "nodeReRegistrationTimeout": -1,
        "defaultClientScopes": [
        ],
        "optionalClientScopes": [],
        "access": {
            "view": True,
            "configure": True,
            "manage": True,
        },
    }

    default_uid_mapper = {
        "name": "userid_mapper",
        "protocol": "saml",
        "protocolMapper": "saml-user-attribute-mapper",
        "consentRequired": False,
        "config": {
            "aggregate.attrs": "",
            "attribute.name": "uid",
            "attribute.nameformat": "Basic",
            "friendly.name": "uid",
            "user.attribute": "uid",
        },
    }

    role_mapper_single = {"config": {
        "attribute.name": "Role",
        "attribute.nameformat": "Basic",
        "friendly.name": "role list mapper",
        "single": "true",
    },
        "name": "role_list_mapper",
        "protocol": "saml",
        "protocolMapper": "saml-role-list-mapper",
    }

    if opt.umc_uid_mapper:
        client_payload_saml["protocolMappers"].append(umc_uid_mapper)

    if opt.idp_initiated_sso_url_name:
        client_payload_saml["attributes"]["saml_idp_initiated_sso_url_name"] = opt.idp_initiated_sso_url_name

    if opt.command == "saml/sp" and opt.role_mapping_single_value:
        client_payload_saml["protocolMappers"].append(role_mapper_single)
    if opt.command == "saml/sp" and opt.client_signature_required:
        client_payload_saml["attributes"]["saml.client.signature"] = "true"

    if opt.metadata_url:
        update_saml_metadata_from_xml(opt, client_id, client_payload_saml, opt.metadata_file, opt.no_ssl_verify, opt.umc_uid_mapper)

    if not check_uid_mapper_exists(client_payload_saml["protocolMappers"]):
        client_payload_saml["protocolMappers"].append(default_uid_mapper)

    # log into default realm in case UCS realm doesn't exist yet
    kc_admin = KeycloakAdmin(server_url=opt.keycloak_url, username=opt.binduser, password=opt.bindpwd, realm_name=opt.realm, user_realm_name=DEFAULT_REALM, verify=opt.no_ssl_verify)

    create_or_update_client(kc_admin, client_payload_saml, client_id, opt.force)


def check_uid_mapper_exists(protocol_mappers):
    return any(
        mapper["config"]["attribute.name"] == "urn:oid:0.9.2342.19200300.100.1.1"
        or mapper["config"]["attribute.name"] == "uid"
        for mapper in protocol_mappers
    )


def create_client_scope(opt):
    print("CREATING KEYCLOAK OIDC CLIENT SCOPE.....")

    # log into default realm in case UCS realm doesn't exist yet
    kc_admin = UniventionKeycloakAdmin(opt)

    client_scope_payload = {
        "name": opt.scope_name,
        "protocol": "openid-connect",
    }

    kc_admin.create_client_scope(payload=client_scope_payload, skip_exists=True)

    scopes = kc_admin.get_client_scopes()
    scope_id = next((s['id'] for s in scopes if s['name'] == opt.scope_name), None)

    if opt.add_user_id_mapper:
        sub_mapper_payload = {
            "name": "user_id",
            "protocol": "openid-connect",
            "protocolMapper": "oidc-sub-mapper",
            "config": {
                "access.token.claim": "true",
                "introspection.token.claim": "true",
                "lightweight.claim": "false",
            },
        }
        kc_admin.raw_post(
            f"admin/realms/{opt.realm}/client-scopes/{scope_id}/protocol-mappers/models",
            data=json.dumps(sub_mapper_payload),
        )
        print("Added user-id attribute mapper")

    if opt.add_groups_mapper:
        group_mapper_payload = {
            "name": "groups-mapper",
            "protocol": "openid-connect",
            "protocolMapper": "oidc-group-membership-mapper",
            "config": {
                "claim.name": opt.add_groups_mapper,
                "full.path": "false",
                "id.token.claim": "true",
                "access.token.claim": "true",
                "userinfo.token.claim": "true",
            },
        }
        kc_admin.raw_post(
            f"admin/realms/{opt.realm}/client-scopes/{scope_id}/protocol-mappers/models",
            data=json.dumps(group_mapper_payload),
        )
        print("Added group membership mapper")


def create_oidc_client(opt):
    print("CREATING KEYCLOAK OIDC CLIENT.....")

    # log into default realm in case UCS realm doesn't exist yet
    kc_admin = UniventionKeycloakAdmin(opt)

    # build urls
    server_url = f"https://{opt.host_fqdn}"
    valid_redirect_urls = [opt.app_url, server_url, *opt.redirect_uri] if opt.redirect_uri else [opt.app_url, server_url]

    # authentication flow
    authentication_flow_binding_overrides = {}
    auth_flow = opt.auth_browser_flow
    if auth_flow:
        auth_flow_id = kc_admin.get_authentication_flow_id(auth_flow)
        if not auth_flow_id:
            print(f"Authentication flow: {auth_flow} not found.", file=sys.stderr)
            sys.exit(1)
        authentication_flow_binding_overrides["browser"] = auth_flow_id

    client_payload_oidc = {
        "clientId": opt.client_id,
        "name": opt.name or opt.client_id,
        "description": opt.description,
        "rootUrl": opt.app_url,
        "adminUrl": opt.admin_url,
        "baseUrl": opt.app_url,
        "surrogateAuthRequired": False,
        "enabled": True,
        "alwaysDisplayInConsole": opt.always_display_in_console,
        "clientAuthenticatorType": "client-secret",
        "redirectUris": valid_redirect_urls,
        "webOrigins": opt.web_origins or valid_redirect_urls,  # FIXME: putting in valid_redirect_urls is wrong, as those aren't origins
        "notBefore": 0,
        "bearerOnly": False,
        "consentRequired": bool(opt.consent),
        "standardFlowEnabled": True,
        "implicitFlowEnabled": opt.allow_implicit_flow,
        "directAccessGrantsEnabled": opt.direct_access_grants,
        "serviceAccountsEnabled": opt.service_accounts_enabled,
        "publicClient": opt.public_client,
        "frontchannelLogout": not opt.no_frontchannel_logout,
        "protocol": "openid-connect",
        "attributes": {
            "policyUri": opt.policy_url,
            "access.token.lifespan": opt.access_token_lifespan,
            "frontchannel.logout.url": opt.frontchannel_logout_url,
            "backchannel.logout.url": opt.backchannel_logout_url,
            "standard.token.exchange.enabled": opt.token_exchange_enabled,
            "standard.token.exchange.enableRefreshRequestedTokenType": opt.token_exchange_refresh_token_type,
            "post.logout.redirect.uris": '##'.join(opt.post_logout_redirect_uris),
            "saml.multivalued.roles": "false",
            "saml.force.post.binding": "false",
            "frontchannel.logout.session.required": "false",
            "oauth2.device.authorization.grant.enabled": "false",
            "backchannel.logout.revoke.offline.tokens": opt.backchannel_logout_revoke_session,
            "saml.server.signature.keyinfo.ext": "false",
            "use.refresh.tokens": not opt.disable_refresh_tokens,
            "oidc.ciba.grant.enabled": "false",
            "backchannel.logout.session.required": opt.backchannel_logout_session_required,
            "client_credentials.use_refresh_token": "false",
            "saml.client.signature": "false",
            "require.pushed.authorization.requests": "false",
            "pkce.code.challenge.method": opt.pkce_code_challenge_method,
            "saml.allow.ecp.flow": "false",
            "saml.assertion.signature": "false",
            "id.token.as.detached.signature": "false",
            "client.secret.creation.time": "1661514856",
            "saml.encrypt": "false",
            "saml.server.signature": "false",
            "exclude.session.state.from.auth.response": "false",
            "saml.artifact.binding": "false",
            "saml_force_name_id_format": "false",
            "tls.client.certificate.bound.access.tokens": "false",
            "acr.loa.map": "{}",
            "saml.authnstatement": "false",
            "display.on.consent.screen": "true" if opt.consent else "false",
            "consent.screen.text": opt.consent,
            "token.response.type.bearer.lower-case": "false",
            "saml.onetimeuse.condition": "false",
            "logoUri": opt.logo_url,
            "tosUri": opt.tos_url,
            "request.uris": '##'.join(opt.request_uris),
        },
        "authenticationFlowBindingOverrides": authentication_flow_binding_overrides,
        "fullScopeAllowed": True,
        "nodeReRegistrationTimeout": -1,
        "protocolMappers": [
            {
                "name": "uid",
                "protocol": "openid-connect",
                "protocolMapper": "oidc-usermodel-attribute-mapper",
                "consentRequired": False,
                "config": {
                    "userinfo.token.claim": "true",
                    "user.attribute": "uid",
                    "id.token.claim": "true",
                    "access.token.claim": "true",
                    "claim.name": "uid",
                    "jsonType.label": "String",
                },
            },
            {
                "name": "username",
                "protocol": "openid-connect",
                "protocolMapper": "oidc-usermodel-property-mapper",
                "consentRequired": False,
                "config": {
                    "userinfo.token.claim": "true",
                    "user.attribute": "username",
                    "id.token.claim": "true",
                    "access.token.claim": "true",
                    "claim.name": "preferred_username",
                    "jsonType.label": "String",
                },
            },
            {
                "name": "email",
                "protocol": "openid-connect",
                "protocolMapper": "oidc-usermodel-property-mapper",
                "consentRequired": False,
                "config": {
                    "userinfo.token.claim": "true",
                    "user.attribute": "email",
                    "id.token.claim": "true",
                    "access.token.claim": "true",
                    "claim.name": "email",
                    "jsonType.label": "String",
                },
            },
        ],
        "defaultClientScopes": ["basic", "web-origins", "acr", "profile", "roles", "email", *opt.default_scopes],
        "optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt", *opt.optional_scopes],
        "access": {
            "view": True,
            "configure": True,
            "manage": True,
        },
        "authorizationServicesEnabled": "",
    }

    if opt.add_audience_mapper:
        client_payload_oidc["protocolMappers"].append(
            {
                "name": "audiencemap",
                "protocol": "openid-connect",
                "protocolMapper": "oidc-audience-mapper",
                "consentRequired": False,
                "config": {
                    "included.client.audience": opt.client_id if not opt.audience_to_map else opt.audience_to_map,
                    "id.token.claim": "true",
                    "access.token.claim": "true",
                },
            },
        )

    if opt.add_dn_mapper:
        client_payload_oidc["protocolMappers"].append(
            {
                "name": "dn",
                "protocol": "openid-connect",
                "protocolMapper": "oidc-usermodel-attribute-mapper",
                "consentRequired": False,
                "config": {
                    "access.token.claim": "true",
                    "aggregate.attrs": False,
                    "claim.name": "dn",
                    "id.token.claim": "false",
                    "jsonType.label": "String",
                    "multivalued": False,
                    "user.attribute": "LDAP_ENTRY_DN",
                    "userinfo.token.claim": "false",
                },
            },
        )

    if opt.add_guardian_audience_mapper:
        client_payload_oidc["protocolMappers"].append(
            {
                "name": "guardian-audience",
                "protocol": "openid-connect",
                "protocolMapper": "oidc-audience-mapper",
                "consentRequired": False,
                "config": {
                    "included.client.audience": "guardian",
                    "id.token.claim": "false",
                    "access.token.claim": "true",
                },
            },
        )

    if opt.add_guardian_management_mappers:
        client_payload_oidc["protocolMappers"] += DEFAULT_GUARDIAN_MANAGEMENT_MAPPERS

    for i, audience in enumerate(opt.access_token_audience):
        client_payload_oidc["protocolMappers"].append(
            {
                "name": f"access-audience-{i}",
                "protocol": "openid-connect",
                "protocolMapper": "oidc-audience-mapper",
                "consentRequired": False,
                "config": {
                    "included.client.audience": audience,
                    "id.token.claim": "false",
                    "access.token.claim": "true",
                },
            },
        )

    for i, audience in enumerate(opt.id_token_audience):
        client_payload_oidc["protocolMappers"].append(
            {
                "name": f"id-audience-{i}",
                "protocol": "openid-connect",
                "protocolMapper": "oidc-audience-mapper",
                "consentRequired": False,
                "config": {
                    "included.client.audience": audience,
                    "id.token.claim": "true",
                    "access.token.claim": "false",
                },
            },
        )

    # This clears the default mappers and leave the ICS only
    if opt.add_ics_mappers:
        client_payload_oidc["protocolMappers"] = DEFAULT_ICS_MAPPERS

    if opt.client_secret:
        client_payload_oidc["secret"] = opt.client_secret

    create_or_update_client(kc_admin, client_payload_oidc, opt.client_id, opt.force)

    # seems that backchannel.logout.revoke.offline.tokens can't be set during create
    if opt.backchannel_logout_revoke_session:
        client_id = kc_admin.get_client_id(opt.client_id)
        payload = {'attributes': {'backchannel.logout.revoke.offline.tokens': True}}
        kc_admin.update_client(client_id, payload)


def modify_client_scope_mapper(opt):
    # log into default realm in case UCS realm doesn't exist yet
    kc_admin = KeycloakAdmin(server_url=opt.keycloak_url, username=opt.binduser, password=opt.bindpwd, realm_name=opt.realm, user_realm_name=DEFAULT_REALM, verify=opt.no_ssl_verify)

    scopes = kc_admin.get_client_scopes()
    id_role_list = [scope["id"] for scope in scopes if scope["name"] == "role_list"][0]  # noqa: RUF015

    scope_id = id_role_list
    data_raw = kc_admin.raw_get(
        f"admin/realms/{opt.realm}/client-scopes/{scope_id}/protocol-mappers/models",
    )
    mappers = data_raw.json()

    mapper_test = [mapper for mapper in mappers if mapper["name"] == "role list"][0]  # noqa: RUF015
    id_mapper_role_list = [mapper["id"] for mapper in mappers if mapper["name"] == "role list"][0]  # noqa: RUF015

    mapper_test["config"]["single"] = True

    protocol_mapper_id = id_mapper_role_list
    data_raw = kc_admin.raw_put(
        f"admin/realms/{opt.realm}/client-scopes/{scope_id}/protocol-mappers/models/{protocol_mapper_id}",
        data=json.dumps(mapper_test),
    )


def download_cert_oidc(opt):
    print("Downloading KEYCLOAK OIDC CERT.....")
    if opt.oidc_url:
        oidc_conf_url = opt.oidc_url
    else:
        oidc_conf_url = f"{opt.keycloak_url}realms/ucs/.well-known/openid-configuration"

    oidc_conf = requests.get(oidc_conf_url)
    certs_oidc = oidc_conf.json()["jwks_uri"]
    oidc_cert_json = requests.get(certs_oidc).json()

    cert_list = [key["x5c"][0] for key in oidc_cert_json["keys"] if key["use"] == "sig"]
    cert = cert_list[0] + "\n"

    if opt.as_pem:
        cert_der = b64decode(cert)
        p = Popen(['openssl', 'x509', '-inform', 'DER', '-out', opt.output, '-outform', 'PEM'], stdin=PIPE)
        p.communicate(input=cert_der)
    else:
        with open(opt.output, 'w') as fd:
            fd.write(cert)


def download_cert_saml(opt):
    realm = opt.realm_id or opt.realm
    if opt.saml_url:
        saml_descriptor_url = opt.saml_url
    else:
        saml_descriptor_url = f"{opt.keycloak_url}realms/{realm}/protocol/saml/descriptor"
    saml_descriptor = requests.get(saml_descriptor_url)
    saml_descriptor_xml = ElementTree.fromstring(saml_descriptor.content)
    cert = saml_descriptor_xml.find('.//{http://www.w3.org/2000/09/xmldsig#}X509Certificate').text
    if opt.as_pem:
        cert = DER_cert_to_PEM_cert(b64decode(cert))
    if opt.output:
        with open(opt.output, 'w') as fd:
            fd.write(cert)
    else:
        print(cert)


def get_client_secret(opt):
    print("Obtaining secret for client ...")

    kc_admin = UniventionKeycloakAdmin(opt)
    secrets = kc_admin.get_client_secrets(kc_admin.get_client_id(opt.client_name))
    _print_json(opt.json, secrets)


def register_extensions(opt: Namespace):
    available_extensions = ExtService.extensions()
    unknown_extensions = set(opt.names) - set(available_extensions.keys())
    if unknown_extensions:
        raise RuntimeError(f"Unknown extensions: {','.join(unknown_extensions)}")

    ext_to_process = set(opt.names) & set(available_extensions.keys())
    funcs = {
        ExtType.ACTION: ExtService.register_required_action,
        ExtType.LDAP_MAPPER: ExtService.register_ldap_mapper,
    }

    kc_admin = UniventionKeycloakAdmin(opt)
    service = ExtService(kc_admin)
    for ext_name in ext_to_process:
        ext = available_extensions.get(ext_name)
        func = funcs.get(ext.type)
        if not func:
            raise RuntimeError(f"No register function found for {ext.type}")
        func(service, opt.realm, ext.alias, ext.name)


def unregister_extensions(opt: Namespace):
    available_extensions = ExtService.extensions()
    unknown_extensions = set(opt.names) - set(available_extensions.keys())
    if unknown_extensions:
        raise RuntimeError(f"Unknown extensions: {','.join(unknown_extensions)}")

    ext_to_process = set(opt.names) & set(available_extensions.keys())
    funcs = {
        ExtType.ACTION: ExtService.unregister_required_action,
        ExtType.LDAP_MAPPER: ExtService.unregister_ldap_mapper,
    }

    kc_admin = UniventionKeycloakAdmin(opt)
    service = ExtService(kc_admin)
    for ext_name in ext_to_process:
        ext = available_extensions.get(ext_name)
        func = funcs.get(ext.type)
        if not func:
            raise RuntimeError(f"No unregister function found for {ext.type}")
        func(service, opt.realm, ext.alias)


def parse_args(args: list[str] | None = None) -> Namespace:
    """
    Parse command line arguments.

    :param args: the list of arguments to process (default: `sys.argv[1:]`)
    :returns: a Namespace instance.
    """
    ldap_base = ucr.get("ldap/base")
    domainname = ucr.get("domainname")
    host_fqdn = "%s.%s" % (ucr.get("hostname"), ucr.get("domainname"))
    umc_saml_sp_server = ucr.get("umc/saml/sp-server", host_fqdn)
    keycloak_url = ucr.get("ucs/server/sso/uri", f"https://ucs-sso-ng.{domainname}/".lower())
    keycloak_fqdn = urlparse(keycloak_url).netloc
    kerberos_realm = ucr.get("kerberos/realm")

    if not keycloak_url.endswith("/"):
        keycloak_url = f"{keycloak_url}/"

    no_ucr_available = not (ucr and ldap_base)

    # if keycloak.secret does not exist the default should be the machine account
    default_bindpwdfile = "/etc/keycloak.secret"
    default_binduser = "admin"
    if not exists(default_bindpwdfile) and ucr.get("hostname"):
        default_bindpwdfile = "/etc/machine.secret"
        default_binduser = f"{ucr['hostname']}$"

    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--binddn", default="")
    parser.add_argument("--binduser", default=default_binduser)
    parser.add_argument("--bindpwd", default="")
    parser.add_argument("--bindpwdfile", default=default_bindpwdfile)
    parser.add_argument("--realm", default="ucs")
    parser.add_argument("--keycloak-pwd", default="")
    parser.add_argument("--keycloak-url", required=no_ucr_available, default=keycloak_url)
    parser.add_argument("--no-ssl-verify", action='store_false')
    subparsers = parser.add_subparsers(title="subcommands", description="valid subcommands", required=True, dest="command")

    # realms
    parser_realms = subparsers.add_parser("realms", help="manage realms")
    operation_subparsers = parser_realms.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    get_realms_parser = operation_subparsers.add_parser("get", help="get realms")
    get_realms_parser.add_argument("--json", help="Print json output", default=False, action="store_true")
    get_realms_parser.add_argument("--all", help="Get all realm attributes, not just the name", default=False, action="store_true")
    get_realms_parser.set_defaults(func=get_realms)

    # proxy realms
    #
    # for us "proxy realms" are special realms without user federation but an "external"
    # IDP. External IDP in this case means the same keycloak, just the default ucs realm.
    # We need this to provide different logical IDP's for the SAML configuration of
    # azure domains (they don't allow to have multiple domains use the same Issue/IDP)
    #
    # Default azure domain -> SAML -> Default keycloak realms (ucs)
    # Second azure domain -> SAML -> keycloak proxy realm -> OIDC -> keycloak ucs realm
    # ...
    #
    # creating a proxy realms includes
    # * verify/create an oidc client in the default, ucs realm
    #   * name "realm-aliases-client"
    #   * with attribute mapper for uid and entryUUID
    # * creating a new realm with
    #   * OIDC IDP "ucs-oidc"
    #     * with Attribute Importer mapper for uid and entryUUID
    #     * setup up againts the "realm-aliases-client" of the ucs realm
    #     * sync FORCE
    #   * browser authentication flow Identity Provider Redirector is configured to use "ucs-oidc" IDP as default
    #   * first broker login is TODO
    # * creating a SAML client for azure
    #   * urn:federation:MicrosoftOnline
    #   * Name ID format persistent
    #   * NameID Mapper (base64) for entryUUID
    #   * AttributeStatement Mapper for uid
    # realms
    parser_proxy_realm = subparsers.add_parser("proxy-realms", help="manage proxy-realms")
    operation_subparsers = parser_proxy_realm.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    get_proxy_realm_parser = operation_subparsers.add_parser("get", help="get proxy realms")
    get_proxy_realm_parser.add_argument("--json", help="Print json output", default=False, action="store_true")
    get_proxy_realm_parser.add_argument("--all", help="Get all realm attributes, not just the name", default=False, action="store_true")
    get_proxy_realm_parser.add_argument("--certificate", help="Get realm certificate", default=False, action="store_true")
    get_proxy_realm_parser.set_defaults(func=get_proxy_realm)
    create_proxy_realm_parser = operation_subparsers.add_parser("create", help="create proxy realms")
    create_proxy_realm_parser.add_argument("--json", help="Print json output", default=False, action="store_true")
    create_proxy_realm_parser.add_argument("name", help="Name of the proxy realms")
    create_proxy_realm_parser.set_defaults(func=create_proxy_realm)
    update_proxy_realm_parser = operation_subparsers.add_parser("update", help="update proxy realm config")
    update_proxy_realm_parser.add_argument("name", help="the ID of the proxy realm")
    update_proxy_realm_parser.add_argument("changes", help="json representation of the client attributes to change")
    update_proxy_realm_parser.set_defaults(func=update_proxy_realm)
    delete_proxy_realm_parser = operation_subparsers.add_parser("delete", help="delete proxy realm config")
    delete_proxy_realm_parser.add_argument("name", help="the ID of the proxy realm")
    delete_proxy_realm_parser.set_defaults(func=delete_proxy_realm)

    # saml client
    parser_saml = subparsers.add_parser("saml/sp", help="configure a SAML SP")
    operation_subparsers = parser_saml.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    create_saml_parser = operation_subparsers.add_parser("create", help="create a new SAML SP in Keycloak")
    create_saml_parser.add_argument("--client-signature-required", action="store_true")
    create_saml_parser_subparser = create_saml_parser.add_mutually_exclusive_group(required=True)
    create_saml_parser_subparser.add_argument("--metadata-url", help="download metadata xml from this url (to extract endpoints) and use as clientId (issuer)")
    create_saml_parser_subparser.add_argument("--client-id", help="clientId (issuer) for this SAML client)")
    create_saml_parser.add_argument("--metadata-file")
    create_saml_parser.add_argument("--name", default=None, help="Display name in Keycloak UI")
    create_saml_parser.add_argument("--umc-uid-mapper", action="store_true")
    create_saml_parser.add_argument("--role-mapping-single-value", action="store_true")
    create_saml_parser.add_argument("--assertion-consumer-url-post", default=None)
    create_saml_parser.add_argument("--single-logout-service-url-post", default=None)
    create_saml_parser.add_argument("--single-logout-service-url-redirect", default=None)
    create_saml_parser.add_argument("--idp-initiated-sso-url-name", default=None)
    create_saml_parser.add_argument("--name-id-format", default="transient")
    create_saml_parser.add_argument("--frontchannel-logout-off", default=False, action="store_true", help="Switch off frontchannelLogout")
    create_saml_parser.add_argument("--description", help="Description of the client")
    create_saml_parser.add_argument("--policy-url", help="URL that the client provides to the end-user to read about the how the profile data will be used")
    create_saml_parser.add_argument("--not-enabled", default=False, action="store_true", help="Disable client")
    create_saml_parser.add_argument("--valid-redirect-uris", nargs='+', default=[], help="Provide list of valid redirect URIs")
    create_saml_parser.add_argument("--force", action="store_true", default=False, help="Update the client if it already exists. WARNING: This will undo any changes done through the Keycloak UI or modifications to the client not given as arguments to this command.")
    create_saml_parser.set_defaults(func=create_SAML_client)

    get_saml_parser = operation_subparsers.add_parser("get", help="get SAML SPs from Keycloak")
    get_saml_parser.add_argument("--json", help="Print json output", default=False, action="store_true")
    get_saml_parser.add_argument("--client-id", help="filter for specific clientId for this SAML client)")
    get_saml_parser.add_argument("--all", help="Get all client attributes, not just the clientId", default=False, action="store_true")
    get_saml_parser.set_defaults(func=get_saml_clients)

    update_saml_parser = operation_subparsers.add_parser("update", help="update SAML SP config")
    update_saml_parser.add_argument("clientid", help="the client ID of the SAML SP in keycloak")
    update_saml_parser.add_argument("changes", help="json representation of the client attributes to change")
    update_saml_parser.add_argument("--metadata-file")
    update_saml_parser.set_defaults(func=update_client)

    # saml user attribute mapper
    parser_saml_user_attribute_mapper = subparsers.add_parser("saml-client-user-attribute-mapper", help="Create/get/delete saml-client-user-attribute-mapper for saml clients")
    parser_saml_user_attribute_mapper_subparser = parser_saml_user_attribute_mapper.add_subparsers(title="operation", description="Valid subcommands", required=True, dest="operation")
    parser_create_saml_user_attribute_mapper = parser_saml_user_attribute_mapper_subparser.add_parser("create", help="Create a new saml-client-user-attribute-mapper")
    parser_create_saml_user_attribute_mapper.add_argument("clientid", help="Client ID of the saml client to create the mapper for")
    parser_create_saml_user_attribute_mapper.add_argument("name", help="name of the mapper")
    parser_create_saml_user_attribute_mapper.add_argument("--attribute-nameformat", help="SAML Attribute NameFormat", choices=["URI Reference", "Basic", "Unspecified"], default="Basic")
    parser_create_saml_user_attribute_mapper.add_argument("--user-attribute", help="Name of keycloak user attribute")
    parser_create_saml_user_attribute_mapper.add_argument("--aggregate-attrs", help="Indicates if attribute values should be aggregated with the group attributes", default=False, action="store_true")
    parser_create_saml_user_attribute_mapper.add_argument("--friendly-name", help="An optional, more human-readable form of the attribute's name that can be provided if the actual attribute name is cryptic.")
    parser_create_saml_user_attribute_mapper.add_argument("--attribute-name", help="SAML Attribute Name")
    parser_create_saml_user_attribute_mapper.set_defaults(func=create_saml_user_attribute_mapper)
    parser_delete_saml_user_attribute_mapper = parser_saml_user_attribute_mapper_subparser.add_parser("delete", help="Delete a saml-client-user-attribute-mapper")
    parser_delete_saml_user_attribute_mapper.add_argument("clientid", help="Client ID of the saml client")
    parser_delete_saml_user_attribute_mapper.add_argument("mappername", help="Name of the mapper to delete")
    parser_delete_saml_user_attribute_mapper.set_defaults(func=delete_saml_user_attribute_mapper)
    parser_get_saml_user_attribute_mapper = parser_saml_user_attribute_mapper_subparser.add_parser("get", help="Get the saml-client-user-attribute-mapper for a specific saml client")
    parser_get_saml_user_attribute_mapper.add_argument("clientid", help="Client ID of the saml client")
    parser_get_saml_user_attribute_mapper.add_argument("--json", help="Print json output", default=False, action="store_true")
    parser_get_saml_user_attribute_mapper.add_argument("--all", help="Get all mapper attributes, not just the name", default=False, action="store_true")
    parser_get_saml_user_attribute_mapper.set_defaults(func=get_saml_user_attribute_mapper)

    # saml nameid mapper
    parser_saml_nameid_mapper = subparsers.add_parser("saml-client-nameid-mapper", help="Create/get/delete namid mapper for saml clients")
    parser_saml_nameid_mapper_subparser = parser_saml_nameid_mapper.add_subparsers(title="operation", description="Valid subcommands", required=True, dest="operation")
    parser_create_saml_nameid_mapper = parser_saml_nameid_mapper_subparser.add_parser("create", help="Create a new saml nameid mapper")
    parser_create_saml_nameid_mapper.add_argument("clientid", help="Client ID of the saml client to create the mapper for")
    parser_create_saml_nameid_mapper.add_argument("name", help="name of the mapper")
    parser_create_saml_nameid_mapper.add_argument("--user-attribute", help="Name of keycloak user attribute")
    parser_create_saml_nameid_mapper.add_argument(
        "--mapper-nameid-format",
        help="Name ID Format",
        default="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
        choices=[
            "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
            "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
            "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName",
            "urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName",
            "urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos",
            "urn:oasis:names:tc:SAML:2.0:nameid-format:entity",
            "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
            "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
        ],
    )
    parser_create_saml_nameid_mapper.add_argument("--base64", help="return the base64 encoded value of the attribute in the mapper", default=False, action="store_true")
    parser_create_saml_nameid_mapper.set_defaults(func=create_saml_nameid_mapper)
    parser_delete_saml_nameid_mapper = parser_saml_nameid_mapper_subparser.add_parser("delete", help="Delete a saml-client-user-attribute-mapper")
    parser_delete_saml_nameid_mapper.add_argument("clientid", help="Client ID of the saml client")
    parser_delete_saml_nameid_mapper.add_argument("mappername", help="Name of the mapper to delete")
    parser_delete_saml_nameid_mapper.set_defaults(func=delete_saml_nameid_mapper)
    parser_get_saml_nameid_mapper = parser_saml_nameid_mapper_subparser.add_parser("get", help="Get the saml-client-user-attribute-mapper for a specific saml client")
    parser_get_saml_nameid_mapper.add_argument("clientid", help="Client ID of the saml client")
    parser_get_saml_nameid_mapper.add_argument("--json", help="Print json output", default=False, action="store_true")
    parser_get_saml_nameid_mapper.add_argument("--all", help="Get all mapper attributes, not just the name", default=False, action="store_true")
    parser_get_saml_nameid_mapper.set_defaults(func=get_saml_nameid_mapper)

    # oidc client
    parser_oidc = subparsers.add_parser("oidc/rp", help="configure a OIDC RP")
    operation_subparsers = parser_oidc.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    create_oidc_parser = operation_subparsers.add_parser("create", help="create a new OIDC client in Keycloak")
    create_oidc_parser.add_argument("client_id", metavar='CLIENT_ID')
    create_oidc_parser.add_argument("--client-secret", default="")
    create_oidc_parser.add_argument("--description", default="")
    create_oidc_parser.add_argument("--name", default=None)
    create_oidc_parser.add_argument("--app-url", required=False)
    create_oidc_parser.add_argument("--public-client", action='store_true', default=False)
    create_oidc_parser.add_argument("--host-fqdn", required=no_ucr_available, default=host_fqdn)
    create_oidc_parser.add_argument("--redirect-uri", action='append', help="Valid redirect URIs.")
    create_oidc_parser.add_argument("--direct-access-grants", help='enable "Direct access grants" flow (default: False)', default=False, action="store_true")
    create_oidc_parser.add_argument("--allow-implicit-flow", action='store_true', help="enables support of 'Implicit Flow' for this client.")
    create_oidc_parser.add_argument("--access-token-lifespan", help="Max time before an access token is expired.")
    create_oidc_parser.add_argument("--service-accounts-enabled", action='store_true')
    create_oidc_parser.add_argument("--no-frontchannel-logout", action='store_true', default=False)
    create_oidc_parser.add_argument("--auth-browser-flow", help="Override realm authentication flow bindings for 'Browser Flow'.", default=None)
    create_oidc_parser.add_argument("--add-audience-mapper", action='store_true', default=False, help="Add a audience mapper to add audience to access token.")
    create_oidc_parser.add_argument("--access-token-audience", action="append", default=[], help="Add audiences to Access Tokens.")
    create_oidc_parser.add_argument("--id-token-audience", action="append", default=[], help="Add audiences to ID Tokens.")
    create_oidc_parser.add_argument("--frontchannel-logout-url", help="URL for frontchannel logout that will cause the client to log itself out when a logout request is sent to this realm.")
    create_oidc_parser.add_argument("--backchannel-logout-url", help="URL for backchannel logout that will cause the client to log itself out when a logout request is sent to this realm.")
    create_oidc_parser.add_argument("--backchannel-logout-revoke-session", action='store_true', default=False, help="If this is on, the backchannel logout will revoke the session of the user. If this is off then no session will be revoked.")
    create_oidc_parser.add_argument("--backchannel-logout-session-required", action='store_true', default=False, help="Include the sid in the backchannel logout request. If this is off then no sid will be included in the request.")
    create_oidc_parser.add_argument("--token-exchange-enabled", help="Enable token exchange for this client.", action='store_true', default=False)
    create_oidc_parser.add_argument("--token-exchange-refresh-token-type", help="Controls if the Standard Token Exchange V2 allows to request a refresh token ", default="SAME_SESSION")
    create_oidc_parser.add_argument("--disable-refresh-tokens", action='store_true', default=False, help="If this is on, a refresh_token will not be created and added to the token response. If this is off then a refresh_token will be generated.")
    create_oidc_parser.add_argument("--post-logout-redirect-uris", action="append", default=[], help="URL pattern for valid redirections after logout.")
    create_oidc_parser.add_argument("--always-display-in-console", action="store_true", default=False, help="Always list this client in the Account UI, even if the user does not have an active session.")
    create_oidc_parser.add_argument("--policy-url", default="", help="URL that the Relying Party Client provides to the End-User to read about the how the profile data will be used.")
    create_oidc_parser.add_argument("--logo-url", default="", help="URL that references a logo for the Client application.")
    create_oidc_parser.add_argument("--tos-url", default="", help="URL that the Relying Party Client provides to the End-User to read about the Relying Party's terms of service.")
    create_oidc_parser.add_argument("--request-uris", action="append", default=[], help="List of valid URIs, which can be used as values of 'request_uri' parameter during OpenID Connect authentication request.")
    create_oidc_parser.add_argument("--pkce-code-challenge-method", choices=["", "plain", "S256"], default="", help="Proof Key for Code Exchange Code Challenge Method.")
    create_oidc_parser.add_argument("--default-scopes", action="append", default=[], help="Add required scopes.")
    create_oidc_parser.add_argument("--optional-scopes", action="append", default=[], help="Add optional scopes.")
    create_oidc_parser.add_argument("--consent", help="Contains the text which will be on the consent screen about permissions specific just for this client. If given, users have to consent to client access.")
    create_oidc_parser.add_argument("--web-origins", action="append", default=[], help="Allowed CORS Origins.")
    create_oidc_parser.add_argument("--admin-url", default="", help="URL for the admin url field in the client, this points to the admin interface of the client.")
    create_oidc_parser.add_argument("--audience-to-map", default="", help="Option to configure the audience that will be used if --add-audience-mapper is used and you need a different one from the default value.")
    create_oidc_parser.add_argument("--add-ics-mappers", action='store_true', default=False, help="Creates the mappers needed for the Intercom Service client instead of all the default mappers.")
    create_oidc_parser.add_argument("--force", action="store_true", default=False, help="Update the client if it already exists. WARNING: This will undo any changes done through the Keycloak UI or modifications to the client not given as arguments to this command.")
    guardian_mapper_group = create_oidc_parser.add_mutually_exclusive_group()
    guardian_mapper_group.add_argument("--add-guardian-management-mappers", action='store_true', default=False, help="Add the mappers needed for the guardian management API. This cannot be used together with --add-guardian-audience-mapper.")
    guardian_mapper_group.add_argument("--add-guardian-audience-mapper", action='store_true', default=False, help="Add the guardian audience mapper. This cannot be used together with --add-guardian-management-mappers.")
    create_oidc_parser.add_argument("--add-dn-mapper", action='store_true', default=False, help="Add the LDAP DN mapped into the access token claim 'dn'.")
    create_oidc_parser.set_defaults(func=create_oidc_client)

    get_oidc_secrets_parser = operation_subparsers.add_parser("secret", help="get client secret from Keycloak")
    get_oidc_secrets_parser.add_argument("--json", help="json output", default=False, action="store_true")
    get_oidc_secrets_parser.add_argument("--client-name", required=True)
    get_oidc_secrets_parser.set_defaults(func=get_client_secret)

    get_oidc_parser = operation_subparsers.add_parser("get", help="get OIDC clients from Keycloak")
    get_oidc_parser.add_argument("--json", help="Print json output", default=False, action="store_true")
    get_oidc_parser.add_argument("--client-id", help="filter for specific clientId for this SAML client)")
    get_oidc_parser.add_argument("--all", help="Get all client attributes, not just the clientId", default=False, action="store_true")
    get_oidc_parser.set_defaults(func=get_oidc_clients)

    update_oidc_parser = operation_subparsers.add_parser("update", help="update OIDC RP client")
    update_oidc_parser.add_argument("clientid", help="the client ID of the OIDC RP in keycloak")
    update_oidc_parser.add_argument("changes", help="json representation of the client attributes to change")
    update_oidc_parser.set_defaults(func=update_client, metadata_file=None)

    remove_oidc_parser = operation_subparsers.add_parser("remove", help="remove OIDC RP client")
    remove_oidc_parser.add_argument("clientid", help="the client ID of the OIDC RP in keycloak")
    remove_oidc_parser.set_defaults(func=remove_client)

    # client scope for OIDC
    parser_scope = subparsers.add_parser("scope", help="configure a client scope for OIDC")
    operation_subparsers = parser_scope.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    create_scope_parser = operation_subparsers.add_parser("create", help="create a new client scope in Keycloak")
    create_scope_parser.add_argument("scope_name", metavar='SCOPE_NAME')
    create_scope_parser.add_argument("--add-user-id-mapper", action='store_true', default=False, help="Add subject mapper to the client scope. This sends the user id as a claim in the OIDC token.")
    create_scope_parser.add_argument("--add-groups-mapper", default="", help="Add a group membership mapper that sends the groups of the user as a claim in the OIDC token.")
    create_scope_parser.set_defaults(func=create_client_scope)

    # saml cert
    parser_saml_idp_cert = subparsers.add_parser("saml/idp/cert", help="SAML IdP certificate")
    operation_subparsers = parser_saml_idp_cert.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    get_cert_saml_parser = operation_subparsers.add_parser("get", help="download the SAML IdP signing certificate")
    get_cert_saml_parser.add_argument("--as-pem", action='store_true')
    get_cert_saml_parser.add_argument("--saml-url", default="")
    get_cert_saml_parser.add_argument("--output", help="output file for certificate")
    get_cert_saml_parser.add_argument("--realm-id", help="get certificate for this realm ID")

    get_cert_saml_parser.set_defaults(func=download_cert_saml)

    parser_oidc_op_cert = subparsers.add_parser("oidc/op/cert", help="OIDC provider certificate")
    operation_subparsers = parser_oidc_op_cert.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    get_cert_oidc_parser = operation_subparsers.add_parser("get", help="download the OIDC signing certificate")
    get_cert_oidc_parser.add_argument("--as-pem", action='store_true')
    get_cert_oidc_parser.add_argument("--oidc-url", default="")
    get_cert_oidc_parser.add_argument("--output", required=True)
    get_cert_oidc_parser.set_defaults(func=download_cert_oidc)

    init_parser = subparsers.add_parser("init", help="configure a Keycloak app")
    init_parser.add_argument("--reverse-proxy-url", default="")
    init_parser.add_argument("--ldap", default="")
    init_parser.add_argument("--ldap-base", required=no_ucr_available, default=ldap_base)
    init_parser.add_argument("--ldap-system-user-dn", required=no_ucr_available, default=f"uid=sys-idp-user,cn=users,{ldap_base}")
    init_parser.add_argument("--ldap-system-user-pwdfile", default="/etc/idp-ldap-user.secret")
    init_parser.add_argument("--host-fqdn", required=no_ucr_available, default=host_fqdn)
    init_parser.add_argument("--umc-saml-sp-server", metavar="FQDN", required=no_ucr_available, default=umc_saml_sp_server)
    init_parser.add_argument("--domainname", required=no_ucr_available, default=domainname)
    init_parser.add_argument("--locales", required=no_ucr_available, default=ucr.get("locale"))
    init_parser.add_argument("--default-locale", required=no_ucr_available, default=ucr.get("locale/default"))
    init_parser.add_argument("--check-init-done", help="only check if init has been already executed", default=False, action="store_true")
    init_parser.add_argument("--force", help="force rerun of the keycloak initialization", default=False, action="store_true")
    init_parser.add_argument("--no-saml-assertion-request", action='store_true', default=False, help="Disables the dynamic request for the UCS SAML assertion and generates the required URLs based on a predefined schema (https://<umc-saml-sp-server>/univention/saml/[metadata|slo/]). Helps if UCS is not available when setting up Keycloak.")
    init_parser.add_argument("--no-starttls", action='store_true', default=False, help="Disables the usage of StartTLS in the communication with the LDAP server, unencrypted traffic will be used.")
    init_parser.add_argument("--no-kerberos", action='store_true', default=False, help="Disables the Kerberos support on the LDAP user federation.")
    init_parser.add_argument("--frontchannel-logout-off", default=False, action="store_true", help="Switch off frontchannelLogout")
    init_parser.add_argument("--enable-user-events", default=False, action="store_true", help="Enable realm user events")
    init_parser.add_argument("--cors-frame-ancestors", default="", help="String containing the addresses to be added to the default CORS settings in the realm")
    init_parser.add_argument("--import-users", default=False, action="store_true", help="Enable importing LDAP users into Keycloak")

    init_parser.set_defaults(func=init_keycloak_ucs)

    kerberos_config = subparsers.add_parser("kerberos-config", help="LDAP federation Kerberos configuration")
    operation_subparsers = kerberos_config.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    kerberos_config_subparser = operation_subparsers.add_parser("set", help="Set Kerberos configuration")
    kerberos_config_subparser.add_argument("--server-principal", required=True)
    kerberos_config_subparser.set_defaults(func=set_kerberos_spn)

    ldap_federation = subparsers.add_parser("ldap-federation", help="Manage LDAP federation")
    ldap_federation_subparsers = ldap_federation.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    sp_ldap_federation_get = ldap_federation_subparsers.add_parser("get", help="Get LDAP federation")
    sp_ldap_federation_get.add_argument("--json", help="json output", default=False, action="store_true")
    sp_ldap_federation_get.add_argument("--name", help="name of the ldap provider object")
    sp_ldap_federation_get.set_defaults(func=ldap_federation_get)
    sp_ldap_federation_set = ldap_federation_subparsers.add_parser("set", help="Set config for LDAP federation")
    sp_ldap_federation_set.add_argument("--name", help="name of the ldap provider object")
    sp_ldap_federation_set.add_argument("--config", help="key value pair of config to change", action=ArgparseKeyvalueAction, nargs='+', required=True, metavar="key=value")
    sp_ldap_federation_set.set_defaults(func=ldap_federation_set)

    adhoc_parser = subparsers.add_parser("ad-hoc", help="configure the ad-hoc federation login flow")
    operation_subparsers = adhoc_parser.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    adhoc_parser_opp = operation_subparsers.add_parser("enable", help="create a login flow that uses ad-hoc federation")
    adhoc_parser_opp.add_argument("--udm-user", required=True)
    adhoc_parser_opp.add_argument("--udm-pwd", required=True)
    adhoc_parser_opp.add_argument("--host-fqdn", required=no_ucr_available, default=host_fqdn)
    adhoc_parser_opp.set_defaults(func=create_adhoc_flow)
    adhoc_parser_opp = operation_subparsers.add_parser("create", help="create and configure an Identity Provider")
    adhoc_parser_opp.add_argument("--alias", default="ADFS", required=True)
    adhoc_parser_opp.add_argument("--metadata-url", required=True)
    adhoc_parser_opp.add_argument("--keycloak-federation-remote-identifier", required=no_ucr_available, default=ucr.get("keycloak/federation/remote/identifier"))
    adhoc_parser_opp.add_argument("--keycloak-federation-source-identifier", required=no_ucr_available, default=ucr.get("keycloak/federation/source/identifier"))
    adhoc_parser_opp.set_defaults(func=create_adhoc_idp)

    fa_parser = subparsers.add_parser("2fa", help="configure 2FA on a group given by --group-2fa (default: \"2FA group\")")
    operation_subparsers = fa_parser.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    fa_parser_opp = operation_subparsers.add_parser("enable", help="create a login flow that uses 2FA and activate it")
    fa_parser_opp.add_argument("--group-2fa", default="2FA group")
    fa_parser_opp.add_argument("--ldap-base", required=no_ucr_available, default=ldap_base)
    fa_parser_opp.set_defaults(func=enable_2fa)

    parser_ext = subparsers.add_parser("extension", help="manage extensions")
    operation_subparsers = parser_ext.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")

    ext_register_parser = operation_subparsers.add_parser("register", help="register extension", formatter_class=ArgumentDefaultsHelpFormatter)
    ext_register_parser.add_argument("--names", metavar="name", nargs="+", default=DEFAULT_EXTENTIONS, help="Names of extensions to be activated")
    ext_register_parser.set_defaults(func=register_extensions)

    ext_unregister_parser = operation_subparsers.add_parser("unregister", help="unregister extension", formatter_class=ArgumentDefaultsHelpFormatter)
    ext_unregister_parser.add_argument("--names", metavar="name", nargs="+", default=DEFAULT_EXTENTIONS, help="Names of extensions to be deactivated")
    ext_unregister_parser.set_defaults(func=unregister_extensions)

    # upgrade-config
    parser_upgrade_config = subparsers.add_parser("upgrade-config", help="upgrade keycloak configuration to currently installed version (UCS only)")
    parser_upgrade_config.add_argument("--json", help="json output", default=False, action="store_true")
    parser_upgrade_config.add_argument("--dry-run", help="do nothing, only print", default=False, action="store_true")
    parser_upgrade_config.add_argument("--get-upgrade-steps", help="just print the upgrade steps", default=False, action="store_true")
    parser_upgrade_config.set_defaults(func=upgrade_config)

    # domain-config
    parser_domain_config = subparsers.add_parser("domain-config", help="get/manage keycloak domain config (UCS only)")
    parser_domain_config.add_argument("--json", help="json output", default=False, action="store_true")
    parser_domain_config.add_argument("--get", help="get domain config", default=False, action="store_true")
    # dangerous, hidden, should only by the app itself
    parser_domain_config.add_argument("--set", help=SUPPRESS, action="append")
    # dangerous, hidden, should only be used for tests
    parser_domain_config.add_argument("--set-domain-config-version", help=SUPPRESS)
    # dangerous, hidden, should only be used for tests
    parser_domain_config.add_argument("--set-domain-config-init", help=SUPPRESS)
    parser_domain_config.set_defaults(func=domain_config)

    # user-attribute-ldap-mapper for ldap-provider
    parser_user_attribute_ldap_mapper = subparsers.add_parser("user-attribute-ldap-mapper", help="Create/get/delete user-attribute-ldap-mapper for the ldap-provider")
    parser_user_attribute_ldap_mapper_subparser = parser_user_attribute_ldap_mapper.add_subparsers(title="operation", description="Valid subcommands", required=True, dest="operation")
    parser_create_user_attribute_ldap_mapper = parser_user_attribute_ldap_mapper_subparser.add_parser("create", help="Create a new user-attribute-ldap-mapper")
    parser_create_user_attribute_ldap_mapper.add_argument("attributename", help="Name of the mapper, the LDAP attribute name and the user attribute name in keycloak")
    parser_create_user_attribute_ldap_mapper.set_defaults(func=create_user_attribute_ldap_mapper)
    parser_delete_user_attribute_ldap_mapper = parser_user_attribute_ldap_mapper_subparser.add_parser("delete", help="Delete a new user-attribute-ldap-mapper")
    parser_delete_user_attribute_ldap_mapper.add_argument("attributename", help="Name of the mapper to delete ")
    parser_delete_user_attribute_ldap_mapper.set_defaults(func=delete_user_attribute_ldap_mapper)
    parser_get_user_attribute_ldap_mapper = parser_user_attribute_ldap_mapper_subparser.add_parser("get", help="Get all existing user-attribute-ldap-mapper")
    parser_get_user_attribute_ldap_mapper.add_argument("--json", help="Print json output", default=False, action="store_true")
    parser_get_user_attribute_ldap_mapper.add_argument("--all", help="Get all mapper attributes, not just the name", default=False, action="store_true")
    parser_get_user_attribute_ldap_mapper.add_argument("--user-attributes", help="instead of the name of the mapper, print the internal user attribute name, this value can bes used in saml or oicd mappers", default=False, action="store_true")
    parser_get_user_attribute_ldap_mapper.set_defaults(func=get_user_attribute_ldap_mapper)

    # get keycloak base url
    parser_get_base_url = subparsers.add_parser("get-keycloak-base-url", help="get the base url of keycloak according to the current configuration (ucs/server/sso/uri)")
    parser_get_base_url.add_argument("--json", help="json output", default=False, action="store_true")
    parser_get_base_url.set_defaults(func=get_base_url)

    # client authentication flow
    parser_client_flow = subparsers.add_parser("client-auth-flow", help="manage client authentication flow")
    parser_client_flow.add_argument("--clientid", help="client ID", required=True)
    parser_client_flow.add_argument("--auth-flow", help="authentication flow", default=None)
    parser_client_flow.set_defaults(func=client_auth_flow)

    # message
    parser_messages = subparsers.add_parser("messages", help="manage messages bundles")
    parser_messages_subparser = parser_messages.add_subparsers(title="operation", description="Valid subcommands", required=True, dest="operation")
    # default locales
    parser_messages_get_locales = parser_messages_subparser.add_parser("get-locales", help="get supported locales")
    parser_messages_get_locales.add_argument("--json", help="json output", default=False, action="store_true")
    parser_messages_get_locales.set_defaults(func=messages_get_locales)
    # get
    parser_messages_get = parser_messages_subparser.add_parser("get", help="get messages bundles")
    parser_messages_get.add_argument("language", help="language to manage")
    parser_messages_get.add_argument("--json", help="json output", default=False, action="store_true")
    parser_messages_get.set_defaults(func=messages_get)
    # set
    parser_messages_set = parser_messages_subparser.add_parser("set", help="set messages bundle")
    parser_messages_set.add_argument("language", help="language to manage")
    parser_messages_set.add_argument("key", help="key to set")
    parser_messages_set.add_argument("value", help="value for key")
    parser_messages_set.set_defaults(func=messages_set)
    # delete
    parser_messages_delete = parser_messages_subparser.add_parser("delete", help="delete messages bundle")
    parser_messages_delete.add_argument("language", help="language to manage")
    parser_messages_delete.add_argument("key", help="key to delete")
    parser_messages_delete.set_defaults(func=messages_delete)

    # login links
    parser_login_links = subparsers.add_parser("login-links", help="manage login links")
    parser_login_links_subparser = parser_login_links.add_subparsers(title="operation", description="Valid subcommands", required=True, dest="operation")
    # get
    parser_login_links_get = parser_login_links_subparser.add_parser("get", help="get login links")
    parser_login_links_get.add_argument("language", help="language for login link")
    parser_login_links_get.add_argument("--json", help="json output", default=False, action="store_true")
    parser_login_links_get.set_defaults(func=login_links_get)
    # set
    parser_login_links_set = parser_login_links_subparser.add_parser("set", help="set login links")
    parser_login_links_set.add_argument("language", help="language for login links")
    parser_login_links_set.add_argument("link_number", help=f"login link number (1-{LOGIN_LINKS})", metavar="link_number", choices=[str(x) for x in range(1, LOGIN_LINKS + 1)])
    parser_login_links_set.add_argument("description", help="description for the link")
    parser_login_links_set.add_argument("href", help="link reference")
    parser_login_links_set.set_defaults(func=login_links_set)
    # delete
    parser_login_links_delete = parser_login_links_subparser.add_parser("delete", help="delete login links")
    parser_login_links_delete.add_argument("language", help="language for login link")
    parser_login_links_delete.add_argument("link_number", help=f"login link number (1-{LOGIN_LINKS})", metavar="link_number", choices=[str(x) for x in range(1, LOGIN_LINKS + 1)])
    parser_login_links_delete.set_defaults(func=login_links_delete)

    # legay authentication flow
    parser_legacy_authentication_flow = subparsers.add_parser("legacy-authentication-flow", add_help=False)
    parser_legacy_authentication_flow_operations = parser_legacy_authentication_flow.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    parser_legacy_authentication_flow_create = parser_legacy_authentication_flow_operations.add_parser("create", help="create the legacy authentication flow")
    parser_legacy_authentication_flow_create.add_argument("--flow", help="create the new flow based on this existing authentication flow (default browser).", default="browser")
    parser_legacy_authentication_flow_create.set_defaults(func=create_legacy_authentication_flow)
    parser_legacy_authentication_flow_delete = parser_legacy_authentication_flow_operations.add_parser("delete", help="delete the legacy authentication flow")
    parser_legacy_authentication_flow_delete.set_defaults(func=delete_legacy_authentication_flow)
    parser_legacy_authentication_flow_delete.add_argument("--flow", help="delete the flow (default browser).", default="browser")

    # kerberos condition flow
    parser_conditional_krb_flow = subparsers.add_parser("conditional-krb-authentication-flow", add_help=False)
    parser_conditional_krb_flow_operations = parser_conditional_krb_flow.add_subparsers(title="operation", description="valid subcommands", required=True, dest="operation")
    parser_conditional_krb_flow_create = parser_conditional_krb_flow_operations.add_parser("create", help="create the legacy authentication flow")
    parser_conditional_krb_flow_create.add_argument("--flow", help="create the new flow based on this existing authentication flow (default browser).", default="browser")
    parser_conditional_krb_flow_create.add_argument("--name", help="the name of the new created flow", default="")
    parser_conditional_krb_flow_create.add_argument("--allowed-ip", help="IP address range which is allowed to use kerberos", default=[], action="append")
    parser_conditional_krb_flow_create.set_defaults(func=create_conditional_krb_authentication_flow)
    opt = parser.parse_args(args)
    if opt.binddn:
        opt.binduser = explode_rdn(opt.binddn, 1)[0]
    if not opt.bindpwd:
        if not exists(opt.bindpwdfile):
            parser.error(f"Passwordfile {opt.bindpwdfile} for user {opt.binduser} does not exist.")
        else:
            with open(opt.bindpwdfile) as fd:
                opt.bindpwd = fd.read().strip()
    if opt.command == "init":
        if not exists(opt.ldap_system_user_pwdfile):
            if not opt.ldap_system_user_pwdfile:
                parser.error(f"Passwordfile {opt.ldap_system_user_pwdfile} for user {opt.ldap_system_user_dn} does not exist.")
        else:
            with open(opt.ldap_system_user_pwdfile) as fd:
                opt.ldap_system_user_pwd = fd.read().strip()
    opt.kerberos_realm = kerberos_realm
    opt.keycloak_fqdn = keycloak_fqdn
    return opt


def _print_json(json_switch: bool, message: str) -> None:
    if json_switch:
        print(json.dumps(message, indent=4))
    else:
        print(message)


def ldap_federation_get(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    provider = session.get_user_storage_provider(name=opt.name)
    _print_json(opt.json, provider)


def ldap_federation_set(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    provider_id = session.get_user_storage_provider_id(name=opt.name)
    name = opt.name or DEFAULT_USER_STORAGE_PROVIDER_NAME
    payload = {
        "name": name,
        "providerType": "org.keycloak.storage.UserStorageProvider",
        "parentId": get_realm_id(session, opt.realm),
        "providerId": provider_id,
        "config": {},
    }
    config = {key: [value] for key, value in opt.config.items()}
    payload['config'].update(config)
    modify_component(session, payload)


def login_links_get(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    res = session.get_message_bundles(opt.language)
    links = {}
    for message in res:
        if message.startswith("linkDescription"):
            number = message.split("linkDescription")[-1]
            if number not in links:
                links[number] = {}
            links[number]["description"] = res[message]
        if message.startswith("linkHref"):
            number = message.split("linkHref")[-1]
            if number not in links:
                links[number] = {}
            links[number]["reference"] = res[message]
    _print_json(opt.json, dict(sorted(links.items())))


def login_links_set(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    session.create_message_bundle(opt.language, f"linkDescription{opt.link_number}", opt.description)
    session.create_message_bundle(opt.language, f"linkHref{opt.link_number}", opt.href)


def login_links_delete(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    session.delete_message_bundle(opt.language, f"linkDescription{opt.link_number}")
    session.delete_message_bundle(opt.language, f"linkHref{opt.link_number}")


def messages_get_locales(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    for realm in session.get_realms():
        if realm["realm"] == opt.realm:
            _print_json(opt.json, realm["supportedLocales"])
            return


def messages_get(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    res = session.get_message_bundles(opt.language)
    _print_json(opt.json, res)


def messages_set(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    session.create_message_bundle(opt.language, opt.key, opt.value)


def messages_delete(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    session.delete_message_bundle(opt.language, opt.key)


def create_conditional_krb_subflows(session, opt, payload, flow_level, level, flow_name):
    krb_subflow1 = {
        "displayName": f"Kerberos alternative {flow_name}",
        "requirement": "ALTERNATIVE",
        "priority": payload["priority"],
        "description": "",
        "type": "basic-flow",
    }
    krb_subflow2 = {
        "displayName": f"Kerberos condition {flow_name}",
        "requirement": "CONDITIONAL",
        "description": "",
        "type": "basic-flow",
    }
    condition_execution = {
        "displayName": f"Conditional IP address {flow_name}",
        "requirement": "REQUIRED",
        "providerId": "univention-condition-ipaddress",
        "priority": 0,
    }
    krb_execution = {
        "displayName": f"Kerberos {flow_name}",
        "providerId": "auth-spnego",
        "requirement": "ALTERNATIVE",
        "priority": 1,
    }
    payload = krb_subflow1
    session.create_and_update_authentication_flow_subflow(payload, flow_alias=flow_level[level])
    alias = payload["displayName"]
    payload = krb_subflow2
    session.create_and_update_authentication_flow_subflow(payload, flow_alias=alias)
    alias = payload["displayName"]
    session.create_and_update_authentication_flow_execution(condition_execution, flow_alias=alias)
    session.create_and_update_authentication_flow_execution(krb_execution, flow_alias=alias)

    payload = {
        "alias": f"ipaddressconfig-{flow_name}",
        "config": {
            "ip-subnets": "##".join(opt.allowed_ip) if opt.allowed_ip else "0.0.0.0/0##::/0",
        },
    }

    condition_user_role_id = None
    for exe in session.get_authentication_flow_executions(flow_name):
        if exe.get("authenticationFlow"):
            continue
        if exe["providerId"] == "univention-condition-ipaddress":
            condition_user_role_id = exe["id"]
    create_config_for_execution(session, payload, condition_user_role_id)


def create_conditional_krb_authentication_flow(opt):
    flow_name = opt.name or f"{opt.flow} kerberos conditional"
    session = UniventionKeycloakAdmin(opt)

    payload = {
        "alias": flow_name,
        "providerId": "basic-flow",
        "builtIn": False,
        "topLevel": True,
        "description": f"{opt.flow} based authentication with conditional kerberos authentication",
    }
    session.create_authentication_flow(payload=payload)
    # create all executions and sub-flows in the correct level
    flow_level = {0: flow_name}
    for payload in session.get_authentication_flow_executions(opt.flow):
        level = payload["level"]
        if payload.get("authenticationFlow", False):
            # save level name mapping for flow
            name = f"{payload['displayName']} ({flow_name})"
            flow_level[level + 1] = name
            payload["type"] = "basic-flow"
            payload["displayName"] = name
            session.create_and_update_authentication_flow_subflow(payload, flow_alias=flow_level[level])
        # replace kerberos execution with conditional subflow
        elif payload.get("providerId") == "auth-spnego":
            create_conditional_krb_subflows(session, opt, payload, flow_level, level, flow_name)
        else:
            session.create_and_update_authentication_flow_execution(payload, flow_alias=flow_level[level])


def create_legacy_authentication_flow(opt: Namespace) -> None:
    flow_name = f"{opt.flow} {LEGACY_APP_AUTHORIZATION_NAME}"
    session = UniventionKeycloakAdmin(opt)

    # check existing
    flows = session.get_authentication_flows()
    for flow in flows:
        if flow["alias"] == flow_name:
            print("ERROR: App authorization flow already exists, please remove before re-creating the flow.", file=sys.stderr)
            return 1

    # check source flow exists
    if not any(opt.flow == x["alias"] for x in flows):
        print(f"ERROR: flow \"{opt.flow}\" does not exist!")
        return 1

    # create flow
    payload = {
        "alias": flow_name,
        "providerId": "basic-flow",
        "builtIn": False,
        "topLevel": True,
        "description": "browser based authentication with Univention App Authenticator authorization",
    }
    session.create_authentication_flow(payload=payload)

    # add subflow for "normal steps"
    sub_flow_name = f"Normal Login ({opt.flow} legacy app authorization)"
    payload = {
        "displayName": sub_flow_name,
        "configurable": False,
        "requirement": "REQUIRED",
        "type": "basic-flow",
        "priority": 1,
    }
    session.create_and_update_authentication_flow_subflow(payload, flow_alias=flow_name)

    # create all executions and sub-flows in the correct level
    flow_level = {0: sub_flow_name}
    for payload in session.get_authentication_flow_executions(opt.flow):
        level = payload["level"]
        if payload.get("authenticationFlow", False):
            # save level name mapping for flow
            name = f"{payload['displayName']} ({flow_name})"
            flow_level[level + 1] = name
            payload["type"] = "basic-flow"
            payload["displayName"] = name
            session.create_and_update_authentication_flow_subflow(payload, flow_alias=flow_level[level])
        else:
            session.create_and_update_authentication_flow_execution(payload, flow_alias=flow_level[level])

    # create authenticator
    payload = {
        "requirement": "REQUIRED",
        "displayName": "Univention App Authenticator",
        "requirementChoices": ["REQUIRED", "DISABLED", "ALTERNATIVE"],
        "configurable": True,
        "providerId": "univention-app-authenticator",
        "priority": 2,
    }
    try:
        session.create_and_update_authentication_flow_execution(payload, flow_alias=flow_name)
    except KeycloakGetError as exc:
        if hasattr(exc, "error_message"):
            if "authentication provider found for id: univention-app-authenticator" in exc.error_message.decode('utf-8'):
                msg = "ERROR: This feature is not yet supported by the installed version of the keycloak app.\n"
                msg += "Please update all instances of the keycloak in our domain to the latest version and try again."
                print(msg, file=sys.stderr)
                delete_legacy_authentication_flow(opt)
                return 1
        raise


def delete_legacy_authentication_flow(opt: Namespace) -> None:
    flow_name = f"{opt.flow} {LEGACY_APP_AUTHORIZATION_NAME}"
    session = UniventionKeycloakAdmin(opt)
    flows = session.get_authentication_flows()
    flow = [x for x in flows if x["alias"] == flow_name]
    if flow:
        flow = flow[0]
        # remove flow override from clients
        clients = session.get_clients()
        for client in clients:
            if client.get("authenticationFlowBindingOverrides"):
                if client["authenticationFlowBindingOverrides"].get("browser"):
                    if client["authenticationFlowBindingOverrides"]["browser"] == flow["id"]:
                        client["authenticationFlowBindingOverrides"]["browser"] = ""
                        session.update_client(client["id"], client)
        # remove flow
        session.raw_delete(f"admin/realms/{session.realm_name}/authentication/flows/{flow['id']}")


def get_base_url(opt: Namespace) -> None:
    """check that the keycloak url works and print url"""
    # check that the url works
    if IS_UCS:
        subprocess.call('/usr/lib/univention-directory-policy/univention-policy-update-config-registry')
        ucr.load()
        opt.keycloak_url = ucr.get('ucs/server/sso/uri', f'https://ucs-sso-ng.{ucr.get("domainname")}'.lower())
    try:
        # we always proxy with a trailing /, so make sure we add this here even
        # if  ucs/server/sso/uri has no trailing /
        resp = requests.get(f'{opt.keycloak_url}/')
        assert resp.status_code == 200, f"invalid http status code {resp.status_code}"
    except (requests.exceptions.ConnectionError, AssertionError) as exc:
        print(exc)
        msg = f"ERROR: Could not connect to keycloak server on {opt.keycloak_url}:\n\n\t{exc}\n\n"
        msg += "Please check the UCR settings for keycloak/server/sso/fqdn and keycloak/server/sso/path,\n"
        msg += "on the Keycloak App server and check that it matches the UCR setting for ucs/server/sso/uri on this host.\n"
        msg += "Make sure that keycloak and apache are running on the keycloak server!"
        print(msg, file=sys.stderr)
        return 1
    # remove trailing slash, is this a good idea?
    # the idea is to use this value for
    # "$BASE_URL/realms/ucs/protocol/saml" -> https://ucs-sso-ng.my.domain/realms/ucs/protocol/saml
    url = opt.keycloak_url.strip("/")
    _print_json(opt.json, url)


def client_auth_flow(opt: Namespace) -> None:
    kc_admin = UniventionKeycloakAdmin(opt)
    auth_flow = opt.auth_flow or "browser"

    msg = f"Setting {opt.clientid} authentication flow to {auth_flow}"
    print(f"{msg} ...")

    auth_flow_id = kc_admin.get_authentication_flow_id(auth_flow)
    if not auth_flow_id:
        print(f"Authentication flow: {auth_flow} not found.", file=sys.stderr)
        sys.exit(1)

    client_id = kc_admin.get_client_id(opt.clientid)
    if not client_id:
        print(f"Client: {opt.clientid} not found.", file=sys.stderr)
        sys.exit(1)

    client_data = kc_admin.get_client(client_id)
    client_data["authenticationFlowBindingOverrides"]["browser"] = auth_flow_id

    kc_admin.update_client(client_id, client_data)
    print(f"{msg} done.")


def delete_saml_nameid_mapper(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    session.delete_mapper(opt.clientid, opt.mappername)


def create_saml_nameid_mapper(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    protocol_mapper = "univention-saml-user-attribute-nameid-mapper-base64" if opt.base64 else "saml-user-attribute-nameid-mapper"
    payload = {
        "name": opt.name,
        "protocol": "saml",
        "protocolMapper": protocol_mapper,
        "config": {
            "user.attribute": opt.user_attribute,
            "mapper.nameid.format": opt.mapper_nameid_format,
        },
    }
    session.create_mapper(opt.clientid, payload)


def get_saml_nameid_mapper(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    mappers = session.get_mapper(opt.clientid, "saml-user-attribute-nameid-mapper")
    mappers += session.get_mapper(opt.clientid, "univention-saml-user-attribute-nameid-mapper-base64")
    if not opt.all:
        mappers = [x["name"] for x in mappers]
    _print_json(opt.json, mappers)


def delete_saml_user_attribute_mapper(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    session.delete_mapper(opt.clientid, opt.mappername)


def create_saml_user_attribute_mapper(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    payload = {
        "name": opt.name,
        "protocol": "saml",
        "protocolMapper": "saml-user-attribute-mapper",
        "config": {
            "attribute.nameformat": opt.attribute_nameformat,
            "user.attribute": opt.user_attribute,
            "aggregate.attrs": opt.aggregate_attrs,
            "friendly.name": opt.friendly_name,
            "attribute.name": opt.attribute_name,
        },
    }
    session.create_mapper(opt.clientid, payload)


def get_saml_user_attribute_mapper(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    mappers = session.get_mapper(opt.clientid, "saml-user-attribute-mapper")
    if not opt.all:
        mappers = [x["name"] for x in mappers]
    _print_json(opt.json, mappers)


def get_user_attribute_ldap_mapper(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    mappers = session.get_user_storage_ldap_mappers()
    if opt.user_attributes:
        mappers = [
            x.get("config").get("user.model.attribute")[0]
            for x in mappers
            if x.get("config").get("user.model.attribute")
        ]
    elif not opt.all:
        mappers = [x["name"] for x in mappers]
    _print_json(opt.json, mappers)


def delete_user_attribute_ldap_mapper(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    for mapper in session.get_user_storage_ldap_mappers():
        if mapper["name"] == opt.attributename:
            session.delete_component(mapper["id"])
            break
    else:
        print(f"LDAP provider mapper {opt.attributename} not found, nothing to delete.")


def set_kerberos_spn(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    name = DEFAULT_USER_STORAGE_PROVIDER_NAME
    provider_id = session.get_user_storage_provider_id()
    payload = {
        "name": name,
        "providerType": "org.keycloak.storage.UserStorageProvider",
        "parentId": get_realm_id(session, opt.realm),
        "providerId": provider_id,
        "config": {
            "serverPrincipal": [getattr(opt, "server_principal", f"HTTP/{opt.keycloak_fqdn}@{opt.kerberos_realm}")],
        },
    }
    modify_component(session, payload)


def update_ldap_federation_tls_setting(opt: Namespace) -> None:
    print("updating")
    session = UniventionKeycloakAdmin(opt)
    name = DEFAULT_USER_STORAGE_PROVIDER_NAME
    provider_id = session.get_user_storage_provider_id()
    payload = {
        "name": name,
        "providerType": "org.keycloak.storage.UserStorageProvider",
        "parentId": get_realm_id(session, opt.realm),
        "providerId": provider_id,
        "config": {
            "useTruststoreSpi": ["never"],
        },
    }
    modify_component(session, payload)
    session = UniventionKeycloakAdmin(opt, DEFAULT_REALM)
    name = "ldap-master-admin"
    payload = {
        "name": name,
        "providerType": "org.keycloak.storage.UserStorageProvider",
        "parentId": get_realm_id(session, DEFAULT_REALM),
        "providerId": provider_id,
        "config": {
            "useTruststoreSpi": ["never"],
        },
    }
    modify_component(session, payload)


def create_kerberos_config(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)
    name = DEFAULT_USER_STORAGE_PROVIDER_NAME
    provider_id = session.get_user_storage_provider_id()
    payload = {
        "name": name,
        "providerType": "org.keycloak.storage.UserStorageProvider",
        "parentId": get_realm_id(session, opt.realm),
        "providerId": provider_id,
        "config": {
            "allowKerberosAuthentication": ["true"],
            "kerberosRealm": [opt.kerberos_realm],
            "serverPrincipal": [f"HTTP/{opt.keycloak_fqdn}@{opt.kerberos_realm}"],
            "keyTab": [KERBEROS_KEYTAB_PATH],
        },
    }
    modify_component(session, payload)


def create_user_attribute_ldap_mapper(opt: Namespace) -> None:
    session = UniventionKeycloakAdmin(opt)

    provider_id = session.get_user_storage_provider_id()
    existing_mappers = session.get_user_storage_ldap_mappers(provider_id)
    if opt.attributename in [x["name"] for x in existing_mappers]:
        print(f"LDAP provider mapper {opt.attributename} already exists.")
    else:
        payload = {
            "name": opt.attributename,
            "parentId": provider_id,
            "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
            "providerId": "user-attribute-ldap-mapper",
            "config": {
                "ldap.attribute": [opt.attributename],
                "is.mandatory.in.ldap": ["false"],
                "attribute.force.default": ["false"],
                "is.binary.attribute": ["false"],
                "read.only": ["true"],
                "user.model.attribute": [opt.attributename],
            },
        }
        session.create_component(payload)


def domain_config(opt: Namespace) -> None:
    if IS_UCS:
        kdc = KeycloakDomainConfig(opt.binddn, opt.bindpwd)
        if opt.get:
            versions = kdc.get()
            _print_json(opt.json, versions)
        if opt.set:
            kdc.set(opt.set)
        if opt.set_domain_config_version:
            kdc.set_domain_config_version(opt.set_domain_config_version)
        if opt.set_domain_config_init:
            kdc.set_domain_config_init(opt.set_domain_config_init)
    else:
        print("Only supported on UCS systems")


def upgrade_config(opt: Namespace) -> None:
    if IS_UCS:
        # these are the version that need a config upgrade
        # never change the order of this list, just append
        upgrades = [
            "19.0.2-ucs2",
            "21.1.1-ucs1",
            "22.0.1-ucs1",
        ]
        upgrades.sort(key=LooseVersion)
        kdc = KeycloakDomainConfig(opt.binddn, opt.bindpwd)
        domain_config_version = kdc.get_domain_config_version() or "0"
        # check what needs to be done
        upgrade_steps = [
            version for version in upgrades
            # only those versions that are greater than the current domain config version
            if LooseVersion(version) > LooseVersion(domain_config_version)
            # only up until the currently installed version
            if LooseVersion(version) <= LooseVersion(kdc.current_version)
        ]
        if opt.get_upgrade_steps:
            _print_json(opt.json, upgrade_steps)
            return
        # check if we are already up-to-date
        if not upgrade_steps:
            print(f"Nothing to do, already at domain config version {domain_config_version}")
            return
        for version in upgrade_steps:
            if version == "19.0.2-ucs2":
                print(f"Running update steps for version: {version}")
                if not opt.dry_run:
                    opt.names = ['password', 'ldapmapper', 'self-service']
                    register_extensions(opt)
                    kdc.set_domain_config_version(version)
                    kdc.set_domain_config_init(version)  # new in this version, normally only init init_keycloak
            if version == "21.1.1-ucs1":
                print(f"Running update steps for version: {version}")
                if not opt.dry_run:
                    opt.attributename = "displayName"
                    create_user_attribute_ldap_mapper(opt)
                    opt.attributename = "entryUUID"
                    create_user_attribute_ldap_mapper(opt)
                    create_kerberos_config(opt)
                    kdc.set_domain_config_version(version)
            if version == "22.0.1-ucs1":
                print(f"Running update steps for version: {version}")
                if not opt.dry_run:
                    update_ldap_federation_tls_setting(opt)
                    kdc.set_domain_config_version(version)
    else:
        print("Only supported on UCS systems")


def create_adhoc_idp(opt):
    print("Creating ad hoc federation identity provider...")
    # realm config
    kc_admin = UniventionKeycloakAdmin(opt)

    metadata_info = extract_metadata(opt.metadata_url, opt.no_ssl_verify)

    idp_payload = {
        "addReadTokenRoleOnCreate": False,
        "alias": opt.alias,
        "authenticateByDefault": False,
        "config": {
            "addExtensionsElementWithKeyInfo": "false",
            "allowCreate": "true",
            "allowedClockSkew": "",
            "attributeConsumingServiceIndex": "",
            "authnContextComparisonType": "exact",
            "backchannelSupported": "",
            "encryptionPublicKey": metadata_info["encryption"],
            "entityId": "keycloak_ucs",
            "forceAuthn": "false",
            "hideOnLoginPage": "",
            "loginHint": "false",
            "nameIDPolicyFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
            "postBindingAuthnRequest": "true",
            "postBindingLogout": "false",
            "postBindingResponse": "true",
            "principalAttribute": "sAMAccountName",
            "principalType": "ATTRIBUTE",
            "signatureAlgorithm": "RSA_SHA256",
            "signingCertificate": metadata_info["signing"],
            "signSpMetadata": "false",
            "singleSignOnServiceUrl": metadata_info["sso_logout_url"],
            "syncMode": "IMPORT",
            "useJwksUrl": "true",
            "validateSignature": "true",
            "wantAssertionsEncrypted": "false",
            "wantAssertionsSigned": "false",
            "wantAuthnRequestsSigned": "true",
            "xmlSigKeyInfoKeyNameTransformer": "CERT_SUBJECT",
        },
        "displayName": opt.alias,
        "enabled": True,
        "firstBrokerLoginFlowAlias": "Univention-Authenticator ad hoc federation flow",
        "linkOnly": False,
        "postBrokerLoginFlowAlias": "",
        "providerId": "saml",
        "storeToken": False,
        "trustEmail": False,
        "updateProfileFirstLoginMode": "on",
    }
    kc_admin.create_idp(idp_payload)

    mapper_payload = {
        "config": {
            "attribute.friendly.name": "",
            "attribute.name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
            "attributes": "[]",
            "syncMode": "IMPORT",
            "user.attribute": "email",
        },
        "identityProviderAlias": opt.alias,
        "identityProviderMapper": "saml-user-attribute-idp-mapper",
        "name": "email",
    }
    kc_admin.add_mapper_to_idp(opt.alias, mapper_payload)

    mapper_payload = {
        "config": {
            "attribute.friendly.name": "",
            "attribute.name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
            "attributes": "[]",
            "syncMode": "IMPORT",
            "user.attribute": "firstName",
        },
        "identityProviderAlias": opt.alias,
        "identityProviderMapper": "saml-user-attribute-idp-mapper",
        "name": "firstName",
    }
    kc_admin.add_mapper_to_idp(opt.alias, mapper_payload)

    mapper_payload = {
        "config": {
            "attribute.friendly.name": "",
            "attribute.name": "objectGuid",
            "attributes": "[]",
            "syncMode": "IMPORT",
            "user.attribute": opt.keycloak_federation_remote_identifier,
        },
        "identityProviderAlias": opt.alias,
        "identityProviderMapper": "saml-user-attribute-idp-mapper",
        "name": "remoteIdentifier",
    }
    kc_admin.add_mapper_to_idp(opt.alias, mapper_payload)

    mapper_payload = {
        "config": {
            "attribute.friendly.name": "",
            "attribute.name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
            "attributes": "[]",
            "syncMode": "IMPORT",
            "user.attribute": "lastName",
        },
        "identityProviderAlias": opt.alias,
        "identityProviderMapper": "saml-user-attribute-idp-mapper",
        "name": "lastName",
    }
    kc_admin.add_mapper_to_idp(opt.alias, mapper_payload)

    mapper_payload = {
        "config": {
            "attribute": "sourceID",
            "attribute.value": "ADFS",
            "attributes": "[]",
            "syncMode": "INHERIT",
        },
        "identityProviderAlias": opt.alias,
        "identityProviderMapper": "hardcoded-attribute-idp-mapper",
        "name": opt.keycloak_federation_source_identifier,
    }
    kc_admin.add_mapper_to_idp(opt.alias, mapper_payload)

    mapper_payload = {
        "config": {
            "attributes": "[]",
            "syncMode": "IMPORT",
            "target": "LOCAL",
            "template": "external-${ALIAS}-${ATTRIBUTE.sAMAccountName}",
        },
        "identityProviderAlias": opt.alias,
        "identityProviderMapper": "saml-username-idp-mapper",
        "name": "username mapper",
    }
    kc_admin.add_mapper_to_idp(opt.alias, mapper_payload)


def extract_metadata(metadata_url, no_ssl_verify):
    xml_content = requests.get(metadata_url, verify=no_ssl_verify).content
    saml_descriptor_xml = ElementTree.fromstring(xml_content)
    signing_cert = saml_descriptor_xml.find('.//{http://www.w3.org/2000/09/xmldsig#}X509Certificate').text
    encryption_cert = saml_descriptor_xml.findall(".//{http://www.w3.org/2000/09/xmldsig#}X509Certificate")[-1].text
    sso_url = saml_descriptor_xml.find('.//{urn:oasis:names:tc:SAML:2.0:metadata}SingleLogoutService').attrib["Location"]

    metadata_info = {"encryption": encryption_cert, "signing": signing_cert, "sso_logout_url": sso_url}
    return metadata_info


def enable_2fa(opt):
    print("Enabling 2FA ...")
    print(f"Using KC_URL: {opt.keycloak_url}")

    # realm config
    kc_admin = UniventionKeycloakAdmin(opt)

    realm_2fa_role = "2FA role"
    ldap_2fa_group = opt.group_2fa  # FIXME: Check group to use for 2FA

    ldap_component_filter = {'name': DEFAULT_USER_STORAGE_PROVIDER_NAME, 'providerId': DEFAULT_USER_STORAGE_PROVIDER_ID}
    ldap_component_list = kc_admin.get_components(ldap_component_filter)
    provider = ldap_component_list.pop()
    provider_id = provider["id"]
    mapper_id = add_or_replace_ldap_group_mapper(opt, kc_admin, provider, "univention-groups", ldap_2fa_group)

    # Synchronize LDAP groups to Keycloak
    kc_admin.raw_post(f'/admin/realms/{quote(opt.realm, safe="")}/user-storage/{quote(provider_id, safe="")}/mappers/{quote(mapper_id, safe="")}/sync?direction=fedToKeycloak', data={})

    create_realm_role(kc_admin, opt.realm, realm_2fa_role)
    create_realm_group(kc_admin, opt.realm, ldap_2fa_group)  # FIXME: This could be removed if we only use UDM groups
    assign_group_realm_role_by_group_name(kc_admin, opt.realm, ldap_2fa_group, realm_2fa_role)

    # switch to default flow before changing anything
    flow_name = "2fa-browser"
    kc_admin.update_realm(opt.realm, {"browserFlow": "browser"})

    # create browser flow
    create_conditional_2fa_flow(opt, kc_admin, opt.realm, realm_2fa_role, flow_name)

    # change authentication binding to conditional 2fa flow
    kc_admin.update_realm(opt.realm, {"browserFlow": flow_name})


def create_or_replace_realm_role(kc_admin, realm, role):
    REALM_ROLE_BASE = {
        "name": None,
        "composite": False,
        "clientRole": False,
        # "containerId": None,
        "attributes": {},
    }

    # check for existing role
    roles = kc_admin.get_realm_roles()
    for iter_role in roles:
        if role == iter_role["name"]:
            kc_admin.delete_realm_role(role)

    # create payload
    payload = REALM_ROLE_BASE
    payload["name"] = role
    # payload["containerId"] = role

    url = f"admin/realms/{realm}/roles"
    data_raw = kc_admin.raw_post(url, data=json.dumps(payload))
    return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204, 201])


def create_realm_role(kc_admin, realm, role):
    REALM_ROLE_BASE = {
        "name": None,
        "composite": False,
        "clientRole": False,
        # "containerId": None,
        "attributes": {},
    }

    # check for existing role
    roles = kc_admin.get_realm_roles()
    for iter_role in roles:
        if role == iter_role["name"]:
            print("Group already exists")  # Avoid deleting the role, it will cause to unbind role from groups already bind
            return

    # create payload
    payload = REALM_ROLE_BASE
    payload["name"] = role

    url = f"admin/realms/{realm}/roles"
    data_raw = kc_admin.raw_post(url, data=json.dumps(payload))
    return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204, 201])


def create_realm_group(kc_admin, realm, group):
    """
    Create or replace if exists a realm group
    :param kc_admin: Keycloak client
    :param realm: String
    :param group: String
    :return: Keycloak server response (RealmRepresentation)
    """
    REALM_GROUP_BASE = {
        "name": None,
    }

    # check for existing group
    groups = kc_admin.get_groups()
    for iter_group in groups:
        if group == iter_group["name"]:
            print("Group already exists")  # Avoid deleting the group, it will cause to unbind ldap users if the group already exists
            return

    # create payload
    payload = REALM_GROUP_BASE
    payload["name"] = group

    return kc_admin.create_group(payload)


def assign_group_realm_role_by_group_name(kc_admin, realm, group_name, role):
    """
    Assign a keycloak realm role to a group by it's name (rather than it's internal id).
    Users in this group will automatically be part of the assinged role.
    :param kc_admin: Keycloak client
    :param realm: String
    :param group_name: String
    :param role: String
    :return: Keycloak server response (RealmRepresentation)
    """
    groups = kc_admin.get_groups()  # query={"name": group_name}
    groups = [group for group in groups if group["name"] == group_name]

    if not groups:
        print("Warning: Groupname does not exist, realm role can not be linked")
        return
    try:
        group_id = [g["id"] for g in groups if g["name"] == group_name][0]  # noqa: RUF015
    except IndexError:
        group_id = None  # why is group ID allowed to be none?

    # get role id
    try:
        realm_role_id = [r["id"] for r in kc_admin.get_realm_roles() if r["name"] == role][0]  # noqa: RUF015
    except IndexError:
        print(f"WARNING: role {role} does not exist")
        return

    payload = [
        {
            "id": realm_role_id,
            "containerId": realm,
            "clientRole": False,
            "name": role,
        },
    ]

    data_raw = kc_admin.raw_post(f"admin/realms/{realm}/groups/{group_id}/role-mappings/realm", data=json.dumps(payload))
    return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204])


def add_or_replace_ldap_group_mapper(opt, kc_admin, ldap_sp, name, ldap_group):
    """
    Add or replace if exists component mapper of type ldap-attribute-mapper
    :param kc_admin: Keycloak client
    :param ldap_sp: KeycloakREST_LDAPComponentRepresentation
    :param name: String
    :param ldap_group: String
    :return: Keycloak server response (RealmRepresentation)
    """
    LDAP_GROUP_MAPPER = {
        "name": None,
        "parentId": None,
        "providerId": "group-ldap-mapper",
        "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
        "config": {
            "membership.attribute.type": [
                "UID",
            ],
            "group.name.ldap.attribute": [
                "cn",
            ],
            "preserve.group.inheritance": [
                "false",
            ],
            "membership.user.ldap.attribute": [
                "uid",
            ],
            "groups.dn": [
                None,
            ],
            "mode": [
                "READ_ONLY",
            ],
            "user.roles.retrieve.strategy": [
                "LOAD_GROUPS_BY_MEMBER_ATTRIBUTE",
            ],
            "ignore.missing.groups": [
                "false",
            ],
            "membership.ldap.attribute": [
                "memberUid",
            ],
            "memberof.ldap.attribute": [
                "memberOf",
            ],
            "group.object.classes": [
                "univentionGroup",
            ],
            "groups.path": [
                "/",
            ],
            "drop.non.existing.groups.during.sync": [
                "false",
            ],
        },
    }

    payload = LDAP_GROUP_MAPPER
    payload["parentId"] = ldap_sp["id"]
    payload["name"] = name
    payload["config"]["groups.dn"] = [f"cn=groups,{opt.ldap_base}"]

    add_or_replace_mapper(kc_admin, component=payload)

    mapper_component_filter = {'name': name, 'parentId': payload['parentId']}
    component_list = kc_admin.get_components(mapper_component_filter)
    return component_list[0]["id"]


def add_or_replace_mapper(kc_admin, component):
    """
    Add or replace if exists any component mapper
    :param kc_admin: Keycloak client
    :param component: KeycloakREST_ComponentRepresentation
    :return:  Keycloak server response (RealmRepresentation)
    """
    mapper_component_filter = {'name': component["name"], 'parentId': component['parentId']}
    component_list = kc_admin.get_components(mapper_component_filter)
    for mapper in component_list:
        kc_admin.delete_component(mapper["id"])

    return kc_admin.create_component(payload=component)


def create_conditional_2fa_flow(opt, kc_admin, realm, conditional_role, flow_name):
    """
    Create a conditional 2FA-require authentication flow for a given role
    :param kc_admin: Keycloak client
    :param realm: String
    :param conditional_role: String
    :param flow_name: String
    :return: None
    """
    # delete old flow/subflows
    flows = kc_admin.get_authentication_flows()
    for flow in flows:
        if flow["alias"].startswith(flow_name):
            delete_authentication_flow(kc_admin, flow["id"])
            print("Deleted: ", flow["alias"])

    # copy default browser flow
    kc_admin.copy_authentication_flow(payload={"newName": flow_name}, flow_alias="browser")

    # create role condition
    # For compatibility, fetch all executions, filter on possible flows
    # Needed before upgrade to 26.3.1. Can be removed for the next version upgrade
    url = f"admin/realms/{kc_admin.realm_name}/authentication/flows/{flow_name}/executions"
    flows = kc_admin.raw_get(url).json()
    possible_flows = [f"{flow_name} Browser - Conditional 2FA", f"{flow_name} Browser - Conditional OTP"]
    filter_flows = [flow for flow in flows if flow["displayName"] in possible_flows]
    master_subflow = filter_flows[0]["displayName"]

    print("Master subflow: ", master_subflow)
    payload = {"provider": "conditional-user-role", "requirement": "REQUIRED"}
    kc_admin.create_authentication_flow_execution(payload=payload, flow_alias=master_subflow)

    # determine user role execution id and auth-otp-form id
    condition_user_role_id = None
    auth_otp_form = None
    for exe in kc_admin.get_authentication_flow_executions(flow_name):
        if exe.get("authenticationFlow"):
            continue
        if exe["providerId"] == "conditional-user-role":
            condition_user_role_id = exe["id"]
        if exe["providerId"] == "auth-otp-form":
            auth_otp_form = exe["id"]

    # raise role condition prio (twice)
    execution_raise_priority(kc_admin, condition_user_role_id)
    execution_raise_priority(kc_admin, condition_user_role_id)
    execution_raise_priority(kc_admin, auth_otp_form)

    # FIXME: bug in keycloak API means requirement = REQUIRED can not be set at creation
    # if this bug is fixed the following lines may be deleted
    payload_update_required_state = {
        "id": condition_user_role_id,
        "requirement": "REQUIRED",
    }
    try:
        kc_admin.update_authentication_flow_executions(payload=payload_update_required_state, flow_alias=flow_name)
    except KeycloakGetError as exc:
        if exc.response_code != 202:
            raise

    payload_update_required_state = {
        "id": auth_otp_form,
        "requirement": "REQUIRED",
    }
    try:
        kc_admin.update_authentication_flow_executions(payload=payload_update_required_state, flow_alias=flow_name)
    except KeycloakGetError as exc:
        if exc.response_code != 202:
            raise
    # FIXME: end

    # config role condition
    config = {
        "alias": "2fa-role-mapping",
        "config": {
            "condUserRole": conditional_role,
            "negate": "",
        },
    }
    create_config_for_execution(kc_admin, config, condition_user_role_id)

    # disable user configured
    condition_user_configured_id = None
    for exe in kc_admin.get_authentication_flow_executions(flow_name):
        if exe.get("authenticationFlow"):
            continue
        if exe["providerId"] == "conditional-user-configured":
            condition_user_configured_id = exe["id"]

    payload_condition_user = {"id": condition_user_configured_id, "requirement": "DISABLED"}

    # FIXME: keycloak API bug, responds with 202
    try:
        kc_admin.update_authentication_flow_executions(payload_condition_user, flow_name)
    except KeycloakGetError as exc:
        if exc.response_code != 202:
            raise exc


def create_config_for_execution(kc_admin, config, execution_id):
    """
    Create an extended config for an existing flow execution
    :param kc_admin: Keycloak client
    :param config: KeycloakREST_ExecutionConfigRepresentation
    :param execution_id: String
    :return: Keycloak server response (RealmRepresentation)
    """
    url = f"admin/realms/{kc_admin.realm_name}/authentication/executions/{execution_id}/config"
    data_raw = kc_admin.raw_post(url, data=json.dumps(config))
    return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204, 201])


def delete_authentication_flow(kc_admin, flow_id):
    data_raw = kc_admin.raw_delete(f"admin/realms/{kc_admin.realm_name}/authentication/flows/{flow_id}")
    return raise_error_from_response(data_raw, KeycloakError, expected_codes=[204])


def execution_raise_priority(kc_admin, execution_id):
    """
    Raise the priority of a give execution within it's subflow.
    Calling this for the top priority execution has no effect, but will not fail.
    :param kc_admin: Keycloak client
    :param execution_id: String
    :return: Keycloak server response (RealmRepresentation)
    """
    payload = {"realm": kc_admin.realm_name, "execution": execution_id}

    url = f"admin/realms/{kc_admin.realm_name}/authentication/executions/{execution_id}/raise-priority"
    data_raw = kc_admin.raw_post(url, data=json.dumps(payload))
    return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204, 201])


def create_adhoc_flow(opt):
    print("Creating ad hoc federation authentication flow...")

    print(f"Using KC_URL: {opt.keycloak_url}")

    # realm config
    kc_admin = UniventionKeycloakAdmin(opt)

    payload_authflow = {
        "newName": "Univention-Authenticator ad hoc federation flow",
    }

    kc_admin.copy_authentication_flow(payload=payload_authflow, flow_alias='first broker login')

    payload_exec_flow = {
        'provider': "univention-authenticator",
    }
    kc_admin.create_authentication_flow_execution(payload=payload_exec_flow, flow_alias='Univention-Authenticator ad hoc federation flow')

    execution_list = kc_admin.get_authentication_flow_executions("Univention-Authenticator ad hoc federation flow")
    ua_execution = [flow for flow in execution_list if flow["displayName"] == "Univention Authenticator"][0]  # noqa: RUF015
    payload_exec_flow = {
        "id": ua_execution["id"],
        "requirement": "REQUIRED",
        "displayName": "Univention Authenticator",
        "requirementChoices": [
            "REQUIRED",
            "DISABLED",
        ],
        "configurable": "true",
        "providerId": "univention-authenticator",
        "level": 0,
        "index": 2,
    }
    try:
        kc_admin.update_authentication_flow_executions(payload=payload_exec_flow, flow_alias='Univention-Authenticator ad hoc federation flow')
    except KeycloakError as exc:
        print(exc)
        if exc.response_code != 202:  # FIXME: function expected 204 response it gets 202
            raise

    # config_id from 'authenticationConfig' in get_authentication_flow_executions
    config_ua = {
        "config": {
            "udm_endpoint": f"https://{opt.host_fqdn}/univention/udm",
            "udm_user": opt.udm_user,
            "udm_password": opt.udm_pwd,
        },
        "alias": "localhost config",
    }

    #  check raise error
    data_raw = kc_admin.raw_post("admin/realms/{}/authentication/executions/{}/config".format(kc_admin.realm_name, ua_execution["id"]), json.dumps(config_ua))
    print("Response code from config Univention-Authenticator: ", data_raw.status_code)


def init_keycloak_ucs(opt):
    locales = opt.locales.split()
    locales_format = [locale[:locale.index("_")] for locale in locales]
    default_locale = opt.default_locale[:opt.default_locale.index("_")]
    if opt.reverse_proxy_url:
        opt.keycloak_url = opt.reverse_proxy_url

    # user federation
    if opt.ldap:
        ucs_ldap_url = opt.ldap
    else:
        ucs_ldap_url = f"ldap://{opt.host_fqdn}:7389"  # "ldap://{FQDN}:{port}"

    print(f"Using bind-dn: {opt.binddn}")

    # log into default realm in case UCS realm doesn't exist yet
    kc_admin = KeycloakAdmin(
        server_url=opt.keycloak_url,
        username=opt.binduser,
        password=opt.bindpwd,
        realm_name=opt.realm,
        user_realm_name=DEFAULT_REALM,
        verify=opt.no_ssl_verify,
    )

    # check if we need to run init
    print("Check if init is needed", end=": ")
    realm = [
        realm for realm in kc_admin.get_realms()
        if realm.get("realm", "") == opt.realm
    ]

    init_complete = True
    if IS_UCS:
        # read current keycloak version as domain config version
        # check if a previous initialization in the domain worked
        kdc = KeycloakDomainConfig(opt.binddn, opt.bindpwd)
        init_complete = kdc.get_domain_config_init()

    if realm and not opt.force and init_complete:
        print("no, already executed")
        sys.exit(0)
    else:
        if opt.check_init_done:
            print("yes, but stopping as requested")
            sys.exit(1)
        else:
            print("yes, continuing init")

    # set locale languages
    realm_payload = {
        "id": DEFAULT_REALM,
        "realm": DEFAULT_REALM,
        "enabled": True,
        "internationalizationEnabled": True,
        "supportedLocales": locales_format,
        "defaultLocale": default_locale,
        "adminTheme": "keycloak",
        "accountTheme": "keycloak",
        "emailTheme": "keycloak",
        "loginTheme": "UCS",
        "browserSecurityHeaders": {
            "contentSecurityPolicyReportOnly": "",
            "xContentTypeOptions": "nosniff",
            "xRobotsTag": "none",
            "xFrameOptions": "",  # we want to overwrite default value, which is: SAMEORIGIN
            "xXSSProtection": "1; mode=block",
            "strictTransportSecurity": "max-age=31536000; includeSubDomains",
        },
        "eventsEnabled": False,
    }

    if opt.cors_frame_ancestors:
        realm_payload["browserSecurityHeaders"]["contentSecurityPolicy"] = f"frame-src 'self'; frame-ancestors 'self' {opt.cors_frame_ancestors} ; object-src 'none';"

    kc_admin.update_realm(DEFAULT_REALM, payload=realm_payload)

    # create ucs realm
    realm_payload["id"] = opt.realm
    realm_payload["realm"] = opt.realm
    realm_payload["eventsEnabled"] = opt.enable_user_events

    try:
        kc_admin.create_realm(payload=realm_payload)
    except KeycloakPostError as exc:
        if exc.response_code != HTTPStatus.CONFLICT:
            raise

        if opt.force:
            kc_admin.update_realm(realm_payload["realm"], payload=realm_payload)

    # create portal saml client
    opt.name = "UMC"
    opt.description = "Univention Management Console"
    opt.single_logout_service_url_post = None
    opt.single_logout_service_url_redirect = None
    opt.assertion_consumer_url_post = None
    opt.valid_redirect_uris = []
    opt.metadata_url = None

    if opt.no_saml_assertion_request:
        opt.client_id = f"https://{opt.umc_saml_sp_server}/univention/saml/metadata"
        opt.single_logout_service_url_post = f"https://{opt.umc_saml_sp_server}/univention/saml/slo/"
        opt.assertion_consumer_url_post = f"https://{opt.umc_saml_sp_server}/univention/saml/"
        opt.valid_redirect_uris = [opt.assertion_consumer_url_post]
    else:
        opt.metadata_url = f"https://{opt.umc_saml_sp_server}/univention/saml/metadata"
    opt.metadata_file = None
    opt.umc_uid_mapper = True  # TODO: get rid of this, replace with reading it from the XML metadata
    opt.name_id_format = None
    opt.idp_initiated_sso_url_name = None
    opt.not_enabled = False
    opt.policy_url = None
    create_SAML_client(opt)

    # user federation ldap provider payload
    ldap_federation_payload = {
        "name": DEFAULT_USER_STORAGE_PROVIDER_NAME,
        "providerId": DEFAULT_USER_STORAGE_PROVIDER_ID,
        "providerType": "org.keycloak.storage.UserStorageProvider",
        "parentId": get_realm_id(kc_admin, opt.realm),
        "config": {
            "pagination": ["true"],
            "fullSyncPeriod": ["-1"],
            "startTls": ["false" if opt.no_starttls else "true"],
            "connectionPooling": ["false" if not opt.no_starttls else "true"],
            "usersDn": [opt.ldap_base],
            "cachePolicy": ["MAX_LIFESPAN"],
            "maxLifespan": ["300000"],
            "useKerberosForPasswordAuthentication": ["false"],
            "importEnabled": ["true" if opt.import_users else "false"],
            "enabled": ["true"],
            "bindCredential": [opt.ldap_system_user_pwd],
            "bindDn": [opt.ldap_system_user_dn],
            "changedSyncPeriod": ["-1"],
            "usernameLDAPAttribute": ["uid"],
            "vendor": ["other"],
            "uuidLDAPAttribute": ["entryUUID"],
            "allowKerberosAuthentication": ["false" if opt.no_kerberos else "true"],
            "kerberosRealm": [opt.kerberos_realm],
            "serverPrincipal": [f"HTTP/{opt.keycloak_fqdn}@{opt.kerberos_realm}"],
            "keyTab": [KERBEROS_KEYTAB_PATH],
            "connectionUrl": [ucs_ldap_url],
            "syncRegistrations": ["false"],
            "authType": ["simple"],
            "debug": ["false"],
            "searchScope": ["2"],
            "useTruststoreSpi": ["never"],
            "usePasswordModifyExtendedOp": ["true"],
            "trustEmail": ["false"],
            "priority": ["0"],
            "userObjectClasses": ["person"],
            "rdnLDAPAttribute": ["uid"],
            "editMode": ["READ_ONLY"],
            "validatePasswordPolicy": ["false"],
            "batchSizeForSync": ["1000"],
            "customUserSearchFilter": ['(uid=*)'],
        },
    }

    # find existing ldap provider or just create if none exists
    ldap_component_id = check_and_create_component(kc_admin, ldap_federation_payload["name"], ldap_federation_payload["providerId"], ldap_federation_payload, force=opt.force)
    print(f"LDAP User Federation Added: {ucs_ldap_url}")

    # User federation mapper (LDAP)
    payload_ldap_mapper = {
        "name": "uid",
        "parentId": ldap_component_id,
        "providerId": "user-attribute-ldap-mapper",
        "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
        "config": {
            "ldap.attribute": [
                "uid",
            ],
            "is.mandatory.in.ldap": [
                "false",
            ],
            "read.only": [
                "true",
            ],
            "user.model.attribute": [
                "uid",
            ],
        },
    }

    firstname_ldap_mapper = {
        "name": "first name",
        "parentId": ldap_component_id,
        "providerId": "user-attribute-ldap-mapper",
        "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
        "config": {
            "ldap.attribute": [
                "givenName",
            ],
            "is.mandatory.in.ldap": [
                "false",
            ],
            "read.only": [
                "true",
            ],
            "user.model.attribute": [
                "firstName",
            ],
        },
    }
    mail_ldap_mapper = {
        "name": "email",
        "parentId": ldap_component_id,
        "providerId": "user-attribute-ldap-mapper",
        "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
        "config": {
            "ldap.attribute": [
                "mailPrimaryAddress",
            ],
            "is.mandatory.in.ldap": [
                "false",
            ],
            "read.only": [
                "true",
            ],
            "user.model.attribute": [
                "email",
            ],
        },
    }
    lastname_ldap_mapper = {
        "name": "last name",
        "parentId": ldap_component_id,
        "providerId": "user-attribute-ldap-mapper",
        "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
        "config": {
            "ldap.attribute": [
                "sn",
            ],
            "is.mandatory.in.ldap": [
                "false",
            ],
            "read.only": [
                "true",
            ],
            "user.model.attribute": [
                "lastName",
            ],
        },
    }
    displayname_ldap_mapper = {
        "name": "displayName",
        "parentId": ldap_component_id,
        "providerId": "user-attribute-ldap-mapper",
        "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
        "config": {
            "ldap.attribute": [
                "displayName",
            ],
            "is.mandatory.in.ldap": [
                "false",
            ],
            "read.only": [
                "true",
            ],
            "user.model.attribute": [
                "displayName",
            ],
        },
    }
    entryuuid_ldap_mapper = {
        "name": "entryUUID",
        "parentId": ldap_component_id,
        "providerId": "user-attribute-ldap-mapper",
        "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
        "config": {
            "ldap.attribute": [
                "entryUUID",
            ],
            "is.mandatory.in.ldap": [
                "false",
            ],
            "read.only": [
                "true",
            ],
            "user.model.attribute": [
                "entryUUID",
            ],
        },
    }

    # find existing uid->uid mapper or create if none exits
    check_and_create_component(kc_admin, payload_ldap_mapper["name"], payload_ldap_mapper["providerId"], payload_ldap_mapper, force=opt.force)

    # Modify default mappers to correct ones
    modify_component(kc_admin, firstname_ldap_mapper)
    modify_component(kc_admin, mail_ldap_mapper)
    modify_component(kc_admin, lastname_ldap_mapper)
    check_and_create_component(kc_admin, displayname_ldap_mapper["name"], displayname_ldap_mapper["providerId"], displayname_ldap_mapper, force=opt.force)
    check_and_create_component(kc_admin, entryuuid_ldap_mapper["name"], entryuuid_ldap_mapper["providerId"], entryuuid_ldap_mapper, force=opt.force)

    # Setting admin level to all Domain Admins
    # Change realm to master
    kc_admin.realm_name = DEFAULT_REALM

    # Admins federation ldap provider payload
    ldap_federation_payload["name"] = "ldap-master-admin"
    ldap_federation_payload["parentId"] = get_realm_id(kc_admin, DEFAULT_REALM)
    ldap_federation_payload["config"]["customUserSearchFilter"] = [
        filter_format(
            "(|(memberOf=%s)(memberOf=%s))", [
                f"cn=Domain Admins,cn=groups,{opt.ldap_base}",
                f"cn=DC Backup Hosts,cn=groups,{opt.ldap_base}",
            ],
        ),
    ]

    # find existing ldap provider or just create if none exists
    ldap_component_id = check_and_create_component(kc_admin, ldap_federation_payload["name"], ldap_federation_payload["providerId"], ldap_federation_payload, force=opt.force)
    print(f"LDAP Domain Admins Federation Added: {ucs_ldap_url}")
    print("Filter: {}".format(ldap_federation_payload["config"]["customUserSearchFilter"]))

    payload_ldap_mapper["name"] = "admin-role"
    payload_ldap_mapper["parentId"] = ldap_component_id
    payload_ldap_mapper["providerId"] = "hardcoded-ldap-role-mapper"
    payload_ldap_mapper["config"] = {"role": ["admin"]}

    # find existing mapper or create if none exits
    check_and_create_component(kc_admin, payload_ldap_mapper["name"], payload_ldap_mapper["providerId"], payload_ldap_mapper, force=opt.force)
    firstname_ldap_mapper["parentId"] = ldap_component_id
    mail_ldap_mapper["parentId"] = ldap_component_id
    lastname_ldap_mapper["parentId"] = ldap_component_id
    modify_component(kc_admin, firstname_ldap_mapper)
    modify_component(kc_admin, mail_ldap_mapper)
    modify_component(kc_admin, lastname_ldap_mapper)

    # register all extensions
    opt.names = DEFAULT_EXTENTIONS
    print(f"Register extenions: {' '.join(opt.names)}")
    register_extensions(opt)

    if IS_UCS:
        # save current keycloak version as domain config version
        # and domain init version
        kdc = KeycloakDomainConfig(opt.binddn, opt.bindpwd)
        print(f"Setting domain config version to {kdc.current_version}")
        kdc.set_domain_config_version(kdc.current_version)
        kdc.set_domain_config_init(kdc.current_version)


def main() -> int:
    """CLI tool to interact with Keycloak."""
    opt = parse_args()
    return opt.func(opt) or 0


if __name__ == "__main__":
    sys.exit(main())
