diff --git a/README-user.md b/README-user.md
index da04fc3d5518f38d2d1501c0dc0886670df214dd..78b258045cf8c5a2db1684e64067877ab3f14f83 100644
--- a/README-user.md
+++ b/README-user.md
@@ -445,6 +445,8 @@ Variable | Description | Required
 `employeenumber` | Employee Number | no
 `employeetype` | Employee Type | no
 `preferredlanguage` | Preferred Language | no
+`idp` \| `ipaidpconfiglink` | External IdP configuration | no
+`idp_user_id` \| `ipaidpsub` | A string that identifies the user at external IdP | no
 `certificate` | List of base-64 encoded user certificates. | no
 `certmapdata` | List of certificate mappings. Either `data` or `certificate` or `issuer` together with `subject` need to be specified. Only usable with IPA versions 4.5 and up. <br>Options: | no
 &nbsp; | `certificate` - Base-64 encoded user certificate, not usable with other certmapdata options. | no
diff --git a/playbooks/user/add-user-external-idp.yml b/playbooks/user/add-user-external-idp.yml
new file mode 100644
index 0000000000000000000000000000000000000000..894878b252f31af8a7c9b6312d23b4f607768867
--- /dev/null
+++ b/playbooks/user/add-user-external-idp.yml
@@ -0,0 +1,12 @@
+---
+- name: Playbook to handle users
+  hosts: ipaserver
+  become: true
+
+  tasks:
+  - name: Create user associated with an external IdP
+    ipauser:
+      ipaadmin_password: SomeADMINpassword
+      name: idpuser
+      idp: keycloak
+      idp_user_id: idpuser@exemple.com
diff --git a/plugins/modules/ipauser.py b/plugins/modules/ipauser.py
index 6059829c252e8d4b1f7474715a0addc76d293141..dcea92f4678184cf1ee1f1dadf3428cbb30cb70e 100644
--- a/plugins/modules/ipauser.py
+++ b/plugins/modules/ipauser.py
@@ -271,6 +271,16 @@ options:
         description: Preferred Language
         type: str
         required: false
+      idp:
+        description: External IdP configuration
+        type: str
+        required: false
+        aliases: ["ipaidpconfiglink"]
+      idp_user_id:
+        description: A string that identifies the user at external IdP
+        type: str
+        required: false
+        aliases: ["ipaidpsub"]
       certificate:
         description: List of base-64 encoded user certificates
         type: list
@@ -528,6 +538,16 @@ options:
     description: Preferred Language
     type: str
     required: false
+  idp:
+    description: External IdP configuration
+    type: str
+    required: false
+    aliases: ["ipaidpconfiglink"]
+  idp_user_id:
+    description: A string that identifies the user at external IdP
+    type: str
+    required: false
+    aliases: ["ipaidpsub"]
   certificate:
     description: List of base-64 encoded user certificates
     type: list
@@ -735,8 +755,8 @@ def gen_args(first, last, fullname, displayname, initials, homedir, gecos,
              mobile, pager, fax, orgunit, title, carlicense, sshpubkey,
              userauthtype, userclass, radius, radiususer, departmentnumber,
              employeenumber, employeetype, preferredlanguage, smb_logon_script,
-             smb_profile_path, smb_home_dir, smb_home_drive, noprivate,
-             nomembers):
+             smb_profile_path, smb_home_dir, smb_home_drive, idp, idp_user_id,
+             noprivate, nomembers):
     # principal, manager, certificate and certmapdata are handled not in here
     _args = {}
     if first is not None:
@@ -809,6 +829,10 @@ def gen_args(first, last, fullname, displayname, initials, homedir, gecos,
         _args["employeetype"] = employeetype
     if preferredlanguage is not None:
         _args["preferredlanguage"] = preferredlanguage
+    if idp is not None:
+        _args["ipaidpconfiglink"] = idp
+    if idp_user_id is not None:
+        _args["ipaidpsub"] = idp_user_id
     if noprivate is not None:
         _args["noprivate"] = noprivate
     if nomembers is not None:
@@ -833,6 +857,7 @@ def check_parameters(  # pylint: disable=unused-argument
         employeenumber, employeetype, preferredlanguage, certificate,
         certmapdata, noprivate, nomembers, preserve, update_password,
         smb_logon_script, smb_profile_path, smb_home_dir, smb_home_drive,
+        idp, ipa_user_id,
 ):
     if state == "present" and action == "user":
         invalid = ["preserve"]
@@ -846,7 +871,7 @@ def check_parameters(  # pylint: disable=unused-argument
             "departmentnumber", "employeenumber", "employeetype",
             "preferredlanguage", "noprivate", "nomembers", "update_password",
             "gecos", "smb_logon_script", "smb_profile_path", "smb_home_dir",
-            "smb_home_drive",
+            "smb_home_drive", "idp", "idp_user_id"
         ]
 
         if state == "present" and action == "member":
@@ -1069,6 +1094,9 @@ def main():
                          elements='dict', required=False),
         noprivate=dict(type='bool', default=None),
         nomembers=dict(type='bool', default=None),
+        idp=dict(type="str", default=None, aliases=['ipaidpconfiglink']),
+        idp_user_id=dict(type="str", default=None,
+                         aliases=['ipaidpconfiglink']),
     )
 
     ansible_module = IPAAnsibleModule(
@@ -1171,6 +1199,8 @@ def main():
     smb_profile_path = ansible_module.params_get("smb_profile_path")
     smb_home_dir = ansible_module.params_get("smb_home_dir")
     smb_home_drive = ansible_module.params_get("smb_home_drive")
+    idp = ansible_module.params_get("idp")
+    idp_user_id = ansible_module.params_get("idp_user_id")
     certificate = ansible_module.params_get("certificate")
     certmapdata = ansible_module.params_get("certmapdata")
     noprivate = ansible_module.params_get("noprivate")
@@ -1204,7 +1234,7 @@ def main():
         radiususer, departmentnumber, employeenumber, employeetype,
         preferredlanguage, certificate, certmapdata, noprivate, nomembers,
         preserve, update_password, smb_logon_script, smb_profile_path,
-        smb_home_dir, smb_home_drive)
+        smb_home_dir, smb_home_drive, idp, idp_user_id)
     certmapdata = convert_certmapdata(certmapdata)
 
     # Use users if names is None
@@ -1298,6 +1328,8 @@ def main():
                 smb_profile_path = user.get("smb_profile_path")
                 smb_home_dir = user.get("smb_home_dir")
                 smb_home_drive = user.get("smb_home_drive")
+                idp = user.get("idp")
+                idp_user_id = user.get("idp_user_id")
                 certificate = user.get("certificate")
                 certmapdata = user.get("certmapdata")
                 noprivate = user.get("noprivate")
@@ -1314,7 +1346,7 @@ def main():
                     employeetype, preferredlanguage, certificate,
                     certmapdata, noprivate, nomembers, preserve,
                     update_password, smb_logon_script, smb_profile_path,
-                    smb_home_dir, smb_home_drive)
+                    smb_home_dir, smb_home_drive, idp, idp_user_id)
                 certmapdata = convert_certmapdata(certmapdata)
 
                 # Check API specific parameters
@@ -1375,6 +1407,19 @@ def main():
                     "smb_profile_path, and smb_home_drive is not supported "
                     "by your IPA version")
 
+            # Check if IdP support is available
+            require_idp = (
+                idp is not None
+                or idp_user_id is not None
+                or userauthtype == "idp"
+            )
+            has_idp_support = ansible_module.ipa_command_param_exists(
+                "user_add", "ipaidpconfiglink"
+            )
+            if require_idp and not has_idp_support:
+                ansible_module.fail_json(
+                    msg="Your IPA version does not support External IdP.")
+
             # Make sure user exists
             res_find = find_user(ansible_module, name)
 
@@ -1390,7 +1435,9 @@ def main():
                     carlicense, sshpubkey, userauthtype, userclass, radius,
                     radiususer, departmentnumber, employeenumber, employeetype,
                     preferredlanguage, smb_logon_script, smb_profile_path,
-                    smb_home_dir, smb_home_drive, noprivate, nomembers)
+                    smb_home_dir, smb_home_drive, idp, idp_user_id, noprivate,
+                    nomembers,
+                )
 
                 if action == "user":
                     # Found the user
diff --git a/tests/user/test_user_idp_attrs.yml b/tests/user/test_user_idp_attrs.yml
new file mode 100644
index 0000000000000000000000000000000000000000..def267b8197bae83d8bd983a8a31b88161105bf5
--- /dev/null
+++ b/tests/user/test_user_idp_attrs.yml
@@ -0,0 +1,107 @@
+---
+- name: Test user
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  become: false
+  gather_facts: false
+  module_defaults:
+    ipauser:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+
+  tasks:
+  - name: Include tasks ../env_freeipa_facts.yml
+    ansible.builtin.include_tasks: ../env_freeipa_facts.yml
+
+  # CLEANUP TEST ITEMS
+
+  - name: Ensure user idpuser is absent
+    ipauser:
+      name: idpuser
+      state: absent
+
+  # CREATE TEST ITEMS
+  - name: Run tests if FreeIPA 4.10.0+ is installed
+    when: ipa_version is version('4.10.0', '>=')
+    block:
+    - name: Ensure IDP provider is present
+      # TODO: Use an ansible-freeipa plugin instead of 'shell'
+      ansible.builtin.shell:
+        cmd: |
+          kinit -c test_krb5_cache admin <<< SomeADMINpassword
+          KRB5CCNAME=test_krb5_cache ipa idp-add keycloak --provider keycloak \
+             --org master \
+             --base-url https://client.ipademo.local:8443/auth \
+             --client-id ipa_oidc_client \
+             --secret  <<< $(echo -e "Secret123\nSecret123")
+          kdestroy -c test_krb5_cache -q -A
+      register: addidp
+      failed_when:
+      - '"Added Identity Provider" not in addidp.stdout'
+      - '"already exists" not in addidp.stderr'
+
+    # TESTS
+
+    - name: Ensure user idpuser is present
+      ipauser:
+        name: idpuser
+        first: IDP
+        last: User
+        userauthtype: idp
+        idp: keycloak
+        idp_user_id: "idpuser@ipademo.local"
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure user idpuser is present again
+      ipauser:
+        name: idpuser
+        first: IDP
+        last: User
+        userauthtype: idp
+        idp: keycloak
+        idp_user_id: "idpuser@ipademo.local"
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Clear 'idp_user_id'
+      ipauser:
+        name: idpuser
+        idp_user_id: ""
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Clear 'idp'
+      ipauser:
+        name: idpuser
+        idp: ""
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure user idpuser is absent
+      ipauser:
+        name: idpuser
+        state: absent
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure user idpuser is absent again
+      ipauser:
+        name: idpuser
+        state: absent
+      register: result
+      failed_when: result.changed or result.failed
+
+
+    # CLEANUP TEST ITEMS
+    - name: Ensure IDP provider is absent
+      # TODO: Use an ansible-freeipa plugin instead of 'shell'
+      ansible.builtin.shell:
+        cmd: |
+          kinit -c test_krb5_cache admin <<< SomeADMINpassword
+          ipa idp-del keycloak
+          kdestroy -c test_krb5_cache -q -A
+    always:
+    - name: Ensure user idpuser is absent
+      ipauser:
+        name: idpuser
+        state: absent