diff --git a/README-idp.md b/README-idp.md
new file mode 100644
index 0000000000000000000000000000000000000000..43590e614dbd80a803a5de7c7de62bbf7a498457
--- /dev/null
+++ b/README-idp.md
@@ -0,0 +1,192 @@
+Idp module
+============
+
+Description
+-----------
+
+The idp module allows to ensure presence and absence of idps.
+
+Features
+--------
+
+* Idp management
+
+
+Supported FreeIPA Versions
+--------------------------
+
+FreeIPA versions 4.4.0 and up are supported by the ipaidp module.
+
+
+Requirements
+------------
+
+**Controller**
+* Ansible version: 2.13
+
+**Node**
+* Supported FreeIPA version (see above)
+
+
+Usage
+=====
+
+Example inventory file
+
+```ini
+[ipaserver]
+ipaserver.test.local
+```
+
+
+Example playbook to make sure keycloak idp my-keycloak-idp is present:
+
+```yaml
+---
+- name: Playbook to manage IPA idp.
+  hosts: ipaserver
+  become: false
+
+  tasks:
+  - name: Ensure keycloak idp my-keycloak-idp is present
+    ipaidp:
+      ipaadmin_password: SomeADMINpassword
+      name: my-keycloak-idp
+      provider: keycloak
+      organization: main
+      base_url: keycloak.idm.example.com:8443/auth
+      client_id: my-client-id
+```
+
+
+Example playbook to make sure keycloak idp my-keycloak-idp is absent:
+
+```yaml
+---
+- name: Playbook to manage IPA idp.
+  hosts: ipaserver
+  become: false
+
+  tasks:
+  - name: Ensure keycloak idp my-keycloak-idp is absent
+    ipaidp:
+      ipaadmin_password: SomeADMINpassword
+      name: my-keycloak-idp
+      delete_continue: true
+      state: absent
+```
+
+
+Example playbook to make sure github idp my-github-idp is present:
+
+```yaml
+---
+- name: Playbook to manage IPA idp.
+  hosts: ipaserver
+  become: false
+
+  tasks:
+  - name: Ensure github idp my-github-idp is present
+    ipaidp:
+      ipaadmin_password: SomeADMINpassword
+      name: my-github-idp
+      provider: github
+      client_id: my-github-client-id
+```
+
+
+Example playbook to make sure google idp my-google-idp is present using provider defaults without specifying provider:
+
+```yaml
+---
+- name: Playbook to manage IPA idp.
+  hosts: ipaserver
+  become: false
+
+  tasks:
+  - name: Ensure google idp my-google-idp is present using provider defaults without specifying provider
+    ipaidp:
+      ipaadmin_password: SomeADMINpassword
+      name: my-google-idp
+      auth_uri: https://accounts.google.com/o/oauth2/auth
+      dev_auth_uri: https://oauth2.googleapis.com/device/code
+      token_uri: https://oauth2.googleapis.com/token
+      keys_uri: https://www.googleapis.com/oauth2/v3/certs
+      userinfo_uri: https://openidconnect.googleapis.com/v1/userinfo
+      client_id: my-google-client-id
+      scope: "openid email"
+      idp_user_id: email
+```
+
+
+Example playbook to make sure google idp my-google-idp is present using provider:
+
+```yaml
+---
+- name: Playbook to manage IPA idp.
+  hosts: ipaserver
+  become: false
+
+  tasks:
+  - name: Ensure google idp my-google-idp is present using provider
+    ipaidp:
+      ipaadmin_password: SomeADMINpassword
+      name: my-google-idp
+      provider: google
+      client_id: my-google-client-id
+```
+
+
+Example playbook to make sure idps my-keycloak-idp, my-github-idp and my-google-idp are absent:
+
+```yaml
+---
+- name: Playbook to manage IPA idp.
+  hosts: ipaserver
+  become: false
+
+  tasks:
+  - name: Ensure idps my-keycloak-idp, my-github-idp and my-google-idp are absent
+    ipaidp:
+      ipaadmin_password: SomeADMINpassword
+      name:
+      - my-keycloak-idp
+      - my-github-idp
+      - my-google-idp
+      delete_continue: true
+      state: absent
+```
+
+
+Variables
+---------
+
+Variable | Description | Required
+-------- | ----------- | --------
+`ipaadmin_principal` | The admin principal is a string and defaults to `admin` | no
+`ipaadmin_password` | The admin password is a string and is required if there is no admin ticket available on the node | no
+`ipaapi_context` | The context in which the module will execute. Executing in a server context is preferred. If not provided context will be determined by the execution environment. Valid values are `server` and `client`. | no
+`ipaapi_ldap_cache` | Use LDAP cache for IPA connection. The bool setting defaults to true. (bool) | false
+`name` \| `cn` | The list of idp name strings. | yes
+auth_uri \| ipaidpauthendpoint | OAuth 2.0 authorization endpoint string. | no
+dev_auth_uri \| ipaidpdevauthendpoint | Device authorization endpoint string. | no
+token_uri \| ipaidptokenendpoint | Token endpoint string. | no
+userinfo_uri \| ipaidpuserinfoendpoint | User information endpoint string. | no
+keys_uri \| ipaidpkeysendpoint | JWKS endpoint string. | no
+issuer_url \| ipaidpissuerurl | The Identity Provider OIDC URL string. | no
+client_id \| ipaidpclientid | OAuth 2.0 client identifier string. | no
+secret \| ipaidpclientsecret | OAuth 2.0 client secret string. | no
+scope \| ipaidpscope | OAuth 2.0 scope string. Multiple scopes separated by space. | no
+idp_user_id \| ipaidpsub | Attribute string for user identity in OAuth 2.0 userinfo. | no
+provider \| ipaidpprovider | Pre-defined template string. This provides the provider defaults, which can be overridden with the other IdP options. Choices: ["google","github","microsoft","okta","keycloak"] | no
+organization \| ipaidporg | Organization ID string or Realm name for IdP provider templates. | no
+base_url \| ipaidpbaseurl | Base URL string for IdP provider templates. | no
+rename \| new_name | New name for the Identity Provider server object. Only with `state: renamed`. | no
+delete_continue \| continue | Continuous mode. Don't stop on errors. Valid only if `state` is `absent`. | no
+`state` | The state to ensure. It can be one of `present`, `absent`, `renamed`, default: `present`. | no
+
+
+Authors
+=======
+
+Thomas Woerner
diff --git a/README.md b/README.md
index a166b0c47f44143a3b0306d70b065bb898dda3b5..7f0c9fc42af494a5c3e2a7fa4a564edcd9aef6f0 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,7 @@ Features
 * Modules for hostgroup management
 * Modules for idoverridegroup management
 * Modules for idoverrideuser management
+* Modules for idp management
 * Modules for idrange management
 * Modules for idview management
 * Modules for location management
@@ -445,6 +446,7 @@ Modules in plugin/modules
 * [ipahostgroup](README-hostgroup.md)
 * [idoverridegroup](README-idoverridegroup.md)
 * [idoverrideuser](README-idoverrideuser.md)
+* [idp](README-idp.md)
 * [idrange](README-idrange.md)
 * [idview](README-idview.md)
 * [ipalocation](README-location.md)
diff --git a/playbooks/idp/idp-absent.yml b/playbooks/idp/idp-absent.yml
new file mode 100644
index 0000000000000000000000000000000000000000..217ca3a7b28c5a36063841d56b7e066d439d28af
--- /dev/null
+++ b/playbooks/idp/idp-absent.yml
@@ -0,0 +1,11 @@
+---
+- name: Idp absent example
+  hosts: ipaserver
+  become: no
+
+  tasks:
+  - name: Ensure github idp my-github-idp is absent
+    ipaidp:
+      ipaadmin_password: SomeADMINpassword
+      name: my-github-idp
+      state: absent
diff --git a/playbooks/idp/idp-present.yml b/playbooks/idp/idp-present.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cdba00d986e7d5638ad09f99c15d21fcbbd384b5
--- /dev/null
+++ b/playbooks/idp/idp-present.yml
@@ -0,0 +1,12 @@
+---
+- name: Idp present example
+  hosts: ipaserver
+  become: no
+
+  tasks:
+  - name: Ensure github idp my-github-idp is present
+    ipaidp:
+      ipaadmin_password: SomeADMINpassword
+      name: my-github-idp
+      provider: github
+      client_id: my-github-client-id
diff --git a/plugins/module_utils/ansible_freeipa_module.py b/plugins/module_utils/ansible_freeipa_module.py
index 8b2c9f8f4af3c252f299df9920e99b3bc2500fe6..dfec4c58f1397922c47ca37d37128ece5173f8e7 100644
--- a/plugins/module_utils/ansible_freeipa_module.py
+++ b/plugins/module_utils/ansible_freeipa_module.py
@@ -30,7 +30,7 @@ __all__ = ["gssapi", "netaddr", "api", "ipalib_errors", "Env",
            "kinit_password", "kinit_keytab", "run", "DN", "VERSION",
            "paths", "tasks", "get_credentials_if_valid", "Encoding",
            "DNSName", "getargspec", "certificate_loader",
-           "write_certificate_list", "boolean"]
+           "write_certificate_list", "boolean", "template_str"]
 
 import os
 # ansible-freeipa requires locale to be C, IPA requires utf-8.
@@ -90,6 +90,7 @@ try:
     except ImportError:
         from ipapython.ipautil import kinit_password, kinit_keytab
     from ipapython.ipautil import run
+    from ipapython.ipautil import template_str
     from ipapython.dn import DN
     from ipapython.version import VERSION
     from ipaplatform.paths import paths
diff --git a/plugins/modules/ipaidp.py b/plugins/modules/ipaidp.py
new file mode 100644
index 0000000000000000000000000000000000000000..a3c1cea75188647b2bf42be5e184344c1994538f
--- /dev/null
+++ b/plugins/modules/ipaidp.py
@@ -0,0 +1,544 @@
+# -*- coding: utf-8 -*-
+
+# Authors:
+#   Thomas Woerner <twoerner@redhat.com>
+#
+# Copyright (C) 2023 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from __future__ import (absolute_import, division, print_function)
+
+__metaclass__ = type
+
+ANSIBLE_METADATA = {
+    "metadata_version": "1.0",
+    "supported_by": "community",
+    "status": ["preview"],
+}
+
+DOCUMENTATION = """
+---
+module: ipaidp
+short_description: Manage FreeIPA idp
+description: Manage FreeIPA idp
+extends_documentation_fragment:
+  - ipamodule_base_docs
+options:
+  name:
+    description: The list of idp name strings.
+    required: true
+    type: list
+    elements: str
+    aliases: ["cn"]
+  auth_uri:
+    description: OAuth 2.0 authorization endpoint
+    required: false
+    type: str
+    aliases: ["ipaidpauthendpoint"]
+  dev_auth_uri:
+    description: Device authorization endpoint
+    required: false
+    type: str
+    aliases: ["ipaidpdevauthendpoint"]
+  token_uri:
+    description: Token endpoint
+    required: false
+    type: str
+    aliases: ["ipaidptokenendpoint"]
+  userinfo_uri:
+    description: User information endpoint
+    required: false
+    type: str
+    aliases: ["ipaidpuserinfoendpoint"]
+  keys_uri:
+    description: JWKS endpoint
+    required: false
+    type: str
+    aliases: ["ipaidpkeysendpoint"]
+  issuer_url:
+    description: The Identity Provider OIDC URL
+    required: false
+    type: str
+    aliases: ["ipaidpissuerurl"]
+  client_id:
+    description: OAuth 2.0 client identifier
+    required: false
+    type: str
+    aliases: ["ipaidpclientid"]
+  secret:
+    description: OAuth 2.0 client secret
+    required: false
+    type: str
+    no_log: true
+    aliases: ["ipaidpclientsecret"]
+  scope:
+    description: OAuth 2.0 scope. Multiple scopes separated by space
+    required: false
+    type: str
+    aliases: ["ipaidpscope"]
+  idp_user_id:
+    description: Attribute for user identity in OAuth 2.0 userinfo
+    required: false
+    type: str
+    aliases: ["ipaidpsub"]
+  provider:
+    description: |
+      Pre-defined template string. This provides the provider defaults, which
+      can be overridden with the other IdP options.
+    required: false
+    type: str
+    choices: ["google","github","microsoft","okta","keycloak"]
+    aliases: ["ipaidpprovider"]
+  organization:
+    description: Organization ID or Realm name for IdP provider templates
+    required: false
+    type: str
+    aliases: ["ipaidporg"]
+  base_url:
+    description: Base URL for IdP provider templates
+    required: false
+    type: str
+    aliases: ["ipaidpbaseurl"]
+  rename:
+    description: |
+      New name the Identity Provider server object. Only with state: renamed.
+    required: false
+    type: str
+    aliases: ["new_name"]
+  delete_continue:
+    description:
+      Continuous mode. Don't stop on errors. Valid only if `state` is `absent`.
+    required: false
+    type: bool
+    aliases: ["continue"]
+  state:
+    description: The state to ensure.
+    choices: ["present", "absent", "renamed"]
+    default: present
+    type: str
+author:
+  - Thomas Woerner (@t-woerner)
+"""
+
+EXAMPLES = """
+# Ensure keycloak idp my-keycloak-idp is present
+- ipaidp:
+    ipaadmin_password: SomeADMINpassword
+    name: my-keycloak-idp
+    provider: keycloak
+    organization: main
+    base_url: keycloak.idm.example.com:8443/auth
+    client_id: my-client-id
+
+# Ensure google idp my-google-idp is present
+- ipaidp:
+    ipaadmin_password: SomeADMINpassword
+    name: my-google-idp
+    auth_uri: https://accounts.google.com/o/oauth2/auth
+    dev_auth_uri: https://oauth2.googleapis.com/device/code
+    token_uri: https://oauth2.googleapis.com/token
+    userinfo_uri: https://openidconnect.googleapis.com/v1/userinfo
+    client_id: my-client-id
+    scope: "openid email"
+    idp_user_id: email
+
+# Ensure google idp my-google-idp is present without using provider
+- ipaidp:
+    ipaadmin_password: SomeADMINpassword
+    name: my-google-idp
+    provider: google
+    client_id: my-google-client-id
+
+# Ensure keycloak idp my-keycloak-idp is absent
+- ipaidp:
+    ipaadmin_password: SomeADMINpassword
+    name: my-keycloak-idp
+    delete_continue: true
+    state: absent
+
+# Ensure idps my-keycloak-idp, my-github-idp and my-google-idp are absent
+- ipaidp:
+    ipaadmin_password: SomeADMINpassword
+    name:
+    - my-keycloak-idp
+    - my-github-idp
+    - my-google-idp
+    delete_continue: true
+    state: absent
+"""
+
+RETURN = """
+"""
+
+
+from ansible.module_utils.ansible_freeipa_module import \
+    IPAAnsibleModule, compare_args_ipa, template_str
+from ansible.module_utils import six
+from copy import deepcopy
+import string
+from itertools import chain
+
+if six.PY3:
+    unicode = str
+
+# Copy from FreeIPA ipaserver/plugins/idp.py
+idp_providers = {
+    'google': {
+        'ipaidpauthendpoint':
+            'https://accounts.google.com/o/oauth2/auth',
+        'ipaidpdevauthendpoint':
+            'https://oauth2.googleapis.com/device/code',
+        'ipaidptokenendpoint':
+            'https://oauth2.googleapis.com/token',
+        'ipaidpuserinfoendpoint':
+            'https://openidconnect.googleapis.com/v1/userinfo',
+        'ipaidpkeysendpoint':
+            'https://www.googleapis.com/oauth2/v3/certs',
+        'ipaidpscope': 'openid email',
+        'ipaidpsub': 'email'},
+    'github': {
+        'ipaidpauthendpoint':
+            'https://github.com/login/oauth/authorize',
+        'ipaidpdevauthendpoint':
+            'https://github.com/login/device/code',
+        'ipaidptokenendpoint':
+            'https://github.com/login/oauth/access_token',
+        'ipaidpuserinfoendpoint':
+            'https://api.github.com/user',
+        'ipaidpscope': 'user',
+        'ipaidpsub': 'login'},
+    'microsoft': {
+        'ipaidpauthendpoint':
+            'https://login.microsoftonline.com/${ipaidporg}/oauth2/v2.0/'
+            'authorize',
+        'ipaidpdevauthendpoint':
+            'https://login.microsoftonline.com/${ipaidporg}/oauth2/v2.0/'
+            'devicecode',
+        'ipaidptokenendpoint':
+            'https://login.microsoftonline.com/${ipaidporg}/oauth2/v2.0/'
+            'token',
+        'ipaidpuserinfoendpoint':
+            'https://graph.microsoft.com/oidc/userinfo',
+        'ipaidpkeysendpoint':
+            'https://login.microsoftonline.com/common/discovery/v2.0/keys',
+        'ipaidpscope': 'openid email',
+        'ipaidpsub': 'email',
+    },
+    'okta': {
+        'ipaidpauthendpoint':
+            'https://${ipaidpbaseurl}/oauth2/v1/authorize',
+        'ipaidpdevauthendpoint':
+            'https://${ipaidpbaseurl}/oauth2/v1/device/authorize',
+        'ipaidptokenendpoint':
+            'https://${ipaidpbaseurl}/oauth2/v1/token',
+        'ipaidpuserinfoendpoint':
+            'https://${ipaidpbaseurl}/oauth2/v1/userinfo',
+        'ipaidpscope': 'openid email',
+        'ipaidpsub': 'email'},
+    'keycloak': {
+        'ipaidpauthendpoint':
+            'https://${ipaidpbaseurl}/realms/${ipaidporg}/protocol/'
+            'openid-connect/auth',
+        'ipaidpdevauthendpoint':
+            'https://${ipaidpbaseurl}/realms/${ipaidporg}/protocol/'
+            'openid-connect/auth/device',
+        'ipaidptokenendpoint':
+            'https://${ipaidpbaseurl}/realms/${ipaidporg}/protocol/'
+            'openid-connect/token',
+        'ipaidpuserinfoendpoint':
+            'https://${ipaidpbaseurl}/realms/${ipaidporg}/protocol/'
+            'openid-connect/userinfo',
+        'ipaidpscope': 'openid email',
+        'ipaidpsub': 'email'},
+}
+
+
+def find_idp(module, name):
+    """Find if a idp with the given name already exist."""
+    try:
+        _result = module.ipa_command("idp_show", name, {"all": True})
+    except Exception:  # pylint: disable=broad-except
+        # An exception is raised if idp name is not found.
+        return None
+
+    return _result["result"]
+
+
+def gen_args(auth_uri, dev_auth_uri, token_uri, userinfo_uri, keys_uri,
+             issuer_url, client_id, secret, scope, idp_user_id, organization,
+             base_url):
+    _args = {}
+    if auth_uri is not None:
+        _args["ipaidpauthendpoint"] = auth_uri
+    if dev_auth_uri is not None:
+        _args["ipaidpdevauthendpoint"] = dev_auth_uri
+    if token_uri is not None:
+        _args["ipaidptokenendpoint"] = token_uri
+    if userinfo_uri is not None:
+        _args["ipaidpuserinfoendpoint"] = userinfo_uri
+    if keys_uri is not None:
+        _args["ipaidpkeysendpoint"] = keys_uri
+    if issuer_url is not None:
+        _args["ipaidpissuerurl"] = issuer_url
+    if client_id is not None:
+        _args["ipaidpclientid"] = client_id
+    if secret is not None:
+        _args["ipaidpclientsecret"] = secret
+    if scope is not None:
+        _args["ipaidpscope"] = scope
+    if idp_user_id is not None:
+        _args["ipaidpsub"] = idp_user_id
+    if organization is not None:
+        _args["ipaidporg"] = organization
+    if base_url is not None:
+        _args["ipaidpbaseurl"] = base_url
+    return _args
+
+
+# Copied and adapted from FreeIPA ipaserver/plugins/idp.py
+def convert_provider_to_endpoints(module, _args, provider):
+    """Convert provider option to auth-uri and token-uri,.."""
+    if provider not in idp_providers:
+        module.fail_json(msg="Provider '%s' is unknown" % provider)
+
+    # For each string in the template check if a variable
+    # is required, it is provided as an option
+    points = deepcopy(idp_providers[provider])
+    _r = string.Template.pattern
+    for (_k, _v) in points.items():
+        # build list of variables to be replaced
+        subs = list(chain.from_iterable(
+                    (filter(None, _s) for _s in _r.findall(_v))))
+        if subs:
+            for _s in subs:
+                if _s not in _args:
+                    module.fail_json(msg="Parameter '%s' is missing" % _s)
+            points[_k] = template_str(_v, _args)
+        elif _k in _args:
+            points[_k] = _args[_k]
+
+    _args.update(points)
+
+
+def main():
+    ansible_module = IPAAnsibleModule(
+        argument_spec=dict(
+            # general
+            name=dict(type="list", elements="str", required=True,
+                      aliases=["cn"]),
+            # present
+            auth_uri=dict(required=False, type="str", default=None,
+                          aliases=["ipaidpauthendpoint"]),
+            dev_auth_uri=dict(required=False, type="str", default=None,
+                              aliases=["ipaidpdevauthendpoint"]),
+            token_uri=dict(required=False, type="str", default=None,
+                           aliases=["ipaidptokenendpoint"]),
+            userinfo_uri=dict(required=False, type="str", default=None,
+                              aliases=["ipaidpuserinfoendpoint"]),
+            keys_uri=dict(required=False, type="str", default=None,
+                          aliases=["ipaidpkeysendpoint"]),
+            issuer_url=dict(required=False, type="str", default=None,
+                            aliases=["ipaidpissuerurl"]),
+            client_id=dict(required=False, type="str", default=None,
+                           aliases=["ipaidpclientid"]),
+            secret=dict(required=False, type="str", default=None,
+                        aliases=["ipaidpclientsecret"], no_log=True),
+            scope=dict(required=False, type="str", default=None,
+                       aliases=["ipaidpscope"]),
+            idp_user_id=dict(required=False, type="str", default=None,
+                             aliases=["ipaidpsub"]),
+            provider=dict(required=False, type="str", default=None,
+                          aliases=["ipaidpprovider"],
+                          choices=["google", "github", "microsoft", "okta",
+                                   "keycloak"]),
+            organization=dict(required=False, type="str", default=None,
+                              aliases=["ipaidporg"]),
+            base_url=dict(required=False, type="str", default=None,
+                          aliases=["ipaidpbaseurl"]),
+            rename=dict(required=False, type="str", default=None,
+                        aliases=["new_name"]),
+            delete_continue=dict(required=False, type="bool", default=None,
+                                 aliases=['continue']),
+            # state
+            state=dict(type="str", default="present",
+                       choices=["present", "absent", "renamed"]),
+        ),
+        supports_check_mode=True,
+        # mutually_exclusive=[],
+        # required_one_of=[]
+    )
+
+    ansible_module._ansible_debug = True
+
+    # Get parameters
+
+    # general
+    names = ansible_module.params_get("name")
+
+    # present
+    auth_uri = ansible_module.params_get("auth_uri")
+    dev_auth_uri = ansible_module.params_get("dev_auth_uri")
+    token_uri = ansible_module.params_get("token_uri")
+    userinfo_uri = ansible_module.params_get("userinfo_uri")
+    keys_uri = ansible_module.params_get("keys_uri")
+    issuer_url = ansible_module.params_get("issuer_url")
+    client_id = ansible_module.params_get("client_id")
+    secret = ansible_module.params_get("secret")
+    scope = ansible_module.params_get("scope")
+    idp_user_id = ansible_module.params_get("idp_user_id")
+    provider = ansible_module.params_get("provider")
+    organization = ansible_module.params_get("organization")
+    base_url = ansible_module.params_get("base_url")
+    rename = ansible_module.params_get("rename")
+
+    delete_continue = ansible_module.params_get("delete_continue")
+
+    # state
+    state = ansible_module.params_get("state")
+
+    # Check parameters
+
+    invalid = []
+
+    if state == "present":
+        if len(names) != 1:
+            ansible_module.fail_json(
+                msg="Only one idp can be added at a time.")
+        if provider:
+            if any([auth_uri, dev_auth_uri, token_uri, userinfo_uri,
+                    keys_uri]):
+                ansible_module.fail_json(
+                    msg="Cannot specify both individual endpoints and IdP "
+                    "provider")
+            if provider not in idp_providers:
+                ansible_module.fail_json(
+                    msg="Provider '%s' is unknown" % provider)
+        else:
+            if not auth_uri:
+                ansible_module.fail_json(
+                    msg="Parameter '%s' is missing" % "auth_uri")
+            if not dev_auth_uri:
+                ansible_module.fail_json(
+                    msg="Parameter '%s' is missing" % "dev_auth_uri")
+            if not token_uri:
+                ansible_module.fail_json(
+                    msg="Parameter '%s' is missing" % "token_uri")
+            if not userinfo_uri:
+                ansible_module.fail_json(
+                    msg="Parameter '%s' is missing" % "userinfo_uri")
+        invalid = ["rename", "delete_continue"]
+    else:
+        # state renamed and absent
+        invalid = ["auth_uri", "dev_auth_uri", "token_uri", "userinfo_uri",
+                   "keys_uri", "issuer_url", "client_id", "secret", "scope",
+                   "idp_user_id", "provider", "organization", "base_url"]
+
+    if state == "renamed":
+        if len(names) != 1:
+            ansible_module.fail_json(
+                msg="Only one permission can be renamed at a time.")
+        invalid += ["delete_continue"]
+
+    if state == "absent":
+        if len(names) < 1:
+            ansible_module.fail_json(msg="No name given.")
+        invalid += ["rename"]
+
+    ansible_module.params_fail_used_invalid(invalid, state)
+
+    # Init
+
+    changed = False
+    exit_args = {}
+
+    # Connect to IPA API
+    with ansible_module.ipa_connect():
+
+        if not ansible_module.ipa_command_exists("idp_add"):
+            ansible_module.fail_json(
+                msg="Managing idp is not supported by your IPA version")
+
+        commands = []
+        for name in names:
+            # Make sure idp exists
+            res_find = find_idp(ansible_module, name)
+
+            # Create command
+            if state == "present":
+
+                # Generate args
+                args = gen_args(auth_uri, dev_auth_uri, token_uri,
+                                userinfo_uri, keys_uri, issuer_url, client_id,
+                                secret, scope, idp_user_id, organization,
+                                base_url)
+
+                if provider is not None:
+                    convert_provider_to_endpoints(ansible_module, args,
+                                                  provider)
+
+                # Found the idp
+                if res_find is not None:
+                    # The parameters ipaidpprovider, ipaidporg and
+                    # ipaidpbaseurl are only available for idp-add to create
+                    # then endpoints using provider, Therefore we have to
+                    # remove them from args.
+                    for arg in ["ipaidpprovider", "ipaidporg",
+                                "ipaidpbaseurl"]:
+                        if arg in args:
+                            del args[arg]
+
+                    # For all settings is args, check if there are
+                    # different settings in the find result.
+                    # If yes: modify
+                    if not compare_args_ipa(ansible_module, args,
+                                            res_find):
+                        commands.append([name, "idp_mod", args])
+                else:
+                    commands.append([name, "idp_add", args])
+
+            elif state == "absent":
+                if res_find is not None:
+                    _args = {}
+                    if delete_continue is not None:
+                        _args = {"continue": delete_continue}
+                    commands.append([name, "idp_del", _args])
+
+            elif state == "renamed":
+                if not rename:
+                    ansible_module.fail_json(msg="No rename value given.")
+
+                if res_find is None:
+                    ansible_module.fail_json(
+                        msg="No idp found to be renamed: '%s'" % (name))
+
+                if name != rename:
+                    commands.append(
+                        [name, "idp_mod", {"rename": rename}])
+
+            else:
+                ansible_module.fail_json(msg="Unkown state '%s'" % state)
+
+        # Execute commands
+
+        changed = ansible_module.execute_ipa_commands(commands)
+
+    # Done
+
+    ansible_module.exit_json(changed=changed, **exit_args)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/idp/test_idp.yml b/tests/idp/test_idp.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c2c78e38482942346ad822c3a0baab72a71ddfd8
--- /dev/null
+++ b/tests/idp/test_idp.yml
@@ -0,0 +1,141 @@
+---
+- name: Test idp
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipaidp:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+
+  tasks:
+
+  # CHECK IF WE HAVE IDP SUPPORT
+
+  - name: Verify if ipd management is supported
+    ansible.builtin.shell:
+      cmd: |
+        echo SomeADMINpassword | kinit -c {{ krb5ccname }} admin
+        RESULT=$(KRB5CCNAME={{ krb5ccname }} ipa command-show idp_add)
+        kdestroy -A -c {{ krb5ccname }}
+        echo $RESULT
+    vars:
+      krb5ccname: "__check_command_idp_add__"
+    register: check_command_idp_add
+
+  - name: Run tests for idp
+    when: not "idp_add" in check_command_idp_add.stderr
+    block:
+
+    # CLEANUP TEST ITEMS
+
+    - name: Ensure idps my-keycloak-idp, my-github-idp and my-google-idp are absent
+      ipaidp:
+        name:
+        - my-keycloak-idp
+        - my-github-idp
+        - my-google-idp
+        delete_continue: true
+        state: absent
+
+    # CREATE TEST ITEMS
+
+    # TESTS
+
+    - name: Ensure keycloak idp my-keycloak-idp is present
+      ipaidp:
+        name: my-keycloak-idp
+        provider: keycloak
+        organization: main
+        base_url: keycloak.idm.example.com:8443/auth
+        client_id: my-client-id
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure keycloak idp my-keycloak-idp is present, again
+      ipaidp:
+        name: my-keycloak-idp
+        provider: keycloak
+        organization: main
+        base_url: keycloak.idm.example.com:8443/auth
+        client_id: my-client-id
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure idp my-keycloak-idp is absent
+      ipaidp:
+        name: my-keycloak-idp
+        delete_continue: true
+        state: absent
+
+    - name: Ensure keycloak idp my-keycloak-idp is failing with missing parameters
+      ipaidp:
+        name: my-keycloak-idp
+        provider: keycloak
+        client_id: my-client-id
+      register: result
+      failed_when: result.changed or not result.failed or
+                   " is missing" not in result.msg
+
+    - name: Ensure github idp my-github-idp is present
+      ipaidp:
+        name: my-github-idp
+        provider: github
+        client_id: my-github-client-id
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure github idp my-github-idp is present, again
+      ipaidp:
+        name: my-github-idp
+        provider: github
+        client_id: my-github-client-id
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure google idp my-google-idp is present using provider defaults without specifying provider
+      ipaidp:
+        name: my-google-idp
+        auth_uri: https://accounts.google.com/o/oauth2/auth
+        dev_auth_uri: https://oauth2.googleapis.com/device/code
+        token_uri: https://oauth2.googleapis.com/token
+        keys_uri: https://www.googleapis.com/oauth2/v3/certs
+        userinfo_uri: https://openidconnect.googleapis.com/v1/userinfo
+        client_id: my-google-client-id
+        scope: "openid email"
+        idp_user_id: email
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure google idp my-google-idp is present using provider defaults without specifying provider, again
+      ipaidp:
+        name: my-google-idp
+        auth_uri: https://accounts.google.com/o/oauth2/auth
+        dev_auth_uri: https://oauth2.googleapis.com/device/code
+        token_uri: https://oauth2.googleapis.com/token
+        keys_uri: https://www.googleapis.com/oauth2/v3/certs
+        userinfo_uri: https://openidconnect.googleapis.com/v1/userinfo
+        client_id: my-google-client-id
+        scope: "openid email"
+        idp_user_id: email
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure google idp my-google-idp is present without changes using provider
+      ipaidp:
+        name: my-google-idp
+        provider: google
+        client_id: my-google-client-id
+      register: result
+      failed_when: result.changed or result.failed
+
+    # CLEANUP TEST ITEMS
+
+    - name: Ensure idps my-keycloak-idp, my-github-idp and my-google-idp are absent
+      ipaidp:
+        name:
+        - my-keycloak-idp
+        - my-github-idp
+        - my-google-idp
+        delete_continue: true
+        state: absent
diff --git a/tests/idp/test_idp_client_context.yml b/tests/idp/test_idp_client_context.yml
new file mode 100644
index 0000000000000000000000000000000000000000..bb384c360e9a5395c7032502a3dda6a468bdd90f
--- /dev/null
+++ b/tests/idp/test_idp_client_context.yml
@@ -0,0 +1,40 @@
+---
+- name: Test idp
+  hosts: ipaclients, ipaserver
+  # It is normally not needed to set "become" to "true" for a module test.
+  # Only set it to true if it is needed to execute commands as root.
+  become: false
+  # Enable "gather_facts" only if "ansible_facts" variable needs to be used.
+  gather_facts: false
+
+  tasks:
+  - name: Include FreeIPA facts.
+    ansible.builtin.include_tasks: ../env_freeipa_facts.yml
+
+  # Test will only be executed if host is not a server.
+  - name: Execute with server context in the client.
+    ipaidp:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: server
+      name: ThisShouldNotWork
+    register: result
+    failed_when: not (result.failed and result.msg is regex("No module named '*ipaserver'*"))
+    when: ipa_host_is_client
+
+# Import basic module tests, and execute with ipa_context set to 'client'.
+# If ipaclients is set, it will be executed using the client, if not,
+# ipaserver will be used.
+#
+# With this setup, tests can be executed against an IPA client, against
+# an IPA server using "client" context, and ensure that tests are executed
+# in upstream CI.
+
+- name: Test idp using client context, in client host.
+  import_playbook: test_idp.yml
+  when: groups['ipaclients']
+  vars:
+    ipa_test_host: ipaclients
+
+- name: Test idp using client context, in server host.
+  import_playbook: test_idp.yml
+  when: groups['ipaclients'] is not defined or not groups['ipaclients']
diff --git a/utils/ansible-freeipa.spec.in b/utils/ansible-freeipa.spec.in
index 69633cc18c2808fd9ed446a8c8cbc3a751e1f7c4..cdfad5074f2b23e3957984d0e1d081dfd90012fc 100644
--- a/utils/ansible-freeipa.spec.in
+++ b/utils/ansible-freeipa.spec.in
@@ -49,6 +49,7 @@ Features
 - Modules for host management
 - Modules for hostgroup management
 - Modules for idoverrideuser management
+- Modules for idp management
 - Modules for idrange management
 - Modules for idview management
 - Modules for location management