> For the complete documentation index, see [llms.txt](https://docs.openiam.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.openiam.com/administration-guide/federation/jit-provisioning/jit-provisioning-reference.md).

# The JIT script contract and claim mapping

The methods a just-in-time provisioning script must implement, a script that compiles, the claim names each identity provider sends, and how to map identity-provider groups to OpenIAM groups.

## What this covers

The code behind [Just-in-time provisioning](/administration-guide/federation/jit-provisioning.md): which methods `AbstractJustInTimeSAMLAuthenticator` obliges you to implement, a complete script for one partner tenant, the claim names a generic SAML identity provider and Entra ID actually send, how to discover the names your own provider sends, and how to translate a group claim into OpenIAM groups under governance. The order the base class runs these in, and the tests that prove the result, are on the task page.

## The methods you must and may implement

| Method                                   | Obligation        | Notes                                                                          |
| ---------------------------------------- | ----------------- | ------------------------------------------------------------------------------ |
| `getEmailType(List<MetadataType> types)` | **abstract**      | Must return a non-null member of `types`                                       |
| `getFirstName()`                         | **abstract**      | —                                                                              |
| `getLastName()`                          | **abstract**      | —                                                                              |
| `getEmail()`                             | **abstract**      | —                                                                              |
| `getPrincipal()`                         | optional override | Defaults to the NameID's value                                                 |
| `populate(User user)`                    | optional override | Called on create only, after the fixed fields                                  |
| `updateUser(User user)`                  | optional override | Empty by default — so with no override, a returning user is not updated at all |

Available to your subclass without declaring anything: `response` (the SAML `org.opensaml.saml.saml2.core.Response`), `nameId`, `accessRightWebService` and `metadataCache`. `serviceProvider` is **private** — you cannot read the provider's own settings from the script, so anything you need about the trust has to be a constant in the script or come from the assertion.

{% hint style="danger" %}
**Do not use `idp/TestJustInTimeSAMLAuthenticator_1.0.groovy` in production.** The shipped example returns a five-character random string from both `getFirstName()` and `getLastName()`, and the *first* email type in an unordered list from `getEmailType`. It is a connectivity test, and you will recognise it in production by users named things like `k7f2q`. Its only production-shaped part is `getEmail()`, which walks the first assertion's attribute statements for the attribute named `urn:openiam:names:attribute:email` and casts the value to `XSString`.
{% endhint %}

## A JIT script that compiles

This keeps the real class declaration and every real signature, and adds population control, a namespaced principal and conservative updates. It is the partner-tenant worked example from the task page.

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```groovy
import java.util.List

import org.openiam.dto.MetadataType
import org.openiam.dto.User
import org.openiam.ui.groovy.saml.AbstractJustInTimeSAMLAuthenticator
import org.opensaml.saml.saml2.core.Attribute
import org.opensaml.core.xml.schema.XSString
import org.apache.commons.collections4.CollectionUtils
import org.apache.commons.lang3.StringUtils
import org.openiam.idm.srvc.user.dto.UserStatusEnum

class PartnerJustInTimeAuthenticator extends AbstractJustInTimeSAMLAuthenticator {

    private static final String TRUSTED_ISSUER = 'https://idp.partner.example.com/saml2'
    private static final String ALLOWED_DOMAIN = '@partner.example.com'

    private String attr(final String name) {
        if (CollectionUtils.isEmpty(response.getAssertions())
                || CollectionUtils.isEmpty(response.getAssertions().get(0).getAttributeStatements())) {
            return null
        }
        for (final Attribute a : response.getAssertions().get(0).getAttributeStatements().get(0).getAttributes()) {
            if (a.getName() == name && CollectionUtils.isNotEmpty(a.getAttributeValues())) {
                return ((XSString) a.getAttributeValues().get(0)).getValue()
            }
        }
        return null
    }

    /** Reject anything outside the approved issuer and domain before an identity can be built. */
    private void assertInPopulation() {
        String issuer = response.getIssuer()?.getValue()
        if (issuer != TRUSTED_ISSUER) {
            throw new SecurityException("JIT refused: unexpected issuer ${issuer}")
        }
        String mail = attr('urn:oid:0.9.2342.19200300.100.1.3')
        if (StringUtils.isBlank(mail) || !StringUtils.endsWithIgnoreCase(mail, ALLOWED_DOMAIN)) {
            throw new SecurityException('JIT refused: email outside approved domain')
        }
    }

    /** Namespace the principal by issuer so two tenants cannot collide. */
    @Override
    protected String getPrincipal() {
        assertInPopulation()
        String subject = nameId?.getValue()
        if (StringUtils.isBlank(subject)) {
            throw new SecurityException('JIT refused: empty NameID')
        }
        return "partner:${StringUtils.lowerCase(subject)}"
    }

    @Override
    protected String getFirstName() {
        String v = attr('urn:oid:2.5.4.42')
        if (StringUtils.isBlank(v)) {
            throw new SecurityException('JIT refused: givenName missing')
        }
        return v
    }

    @Override
    protected String getLastName() {
        String v = attr('urn:oid:2.5.4.4')
        if (StringUtils.isBlank(v)) {
            throw new SecurityException('JIT refused: surname missing')
        }
        return v
    }

    @Override
    protected String getEmail() {
        return StringUtils.lowerCase(attr('urn:oid:0.9.2342.19200300.100.1.3'))
    }

    /** Must return a member of the supplied list, never null. */
    @Override
    protected MetadataType getEmailType(final List<MetadataType> types) {
        MetadataType work = types.find { it.getId() == 'WORK_EMAIL' }
        return work != null ? work : types.get(0)
    }

    /** Runs on create only. Set status and organization deliberately. */
    @Override
    protected void populate(final User user) {
        user.setNickname(null)
        user.setJobCode(null)
        // Set an explicit non-privileged status and an expiry the sponsor process renews.
        user.setStatus(UserStatusEnum.PENDING_START_DATE)
    }

    /** Runs on every later sign-in. Never blank a good value because the assertion omitted it. */
    @Override
    void updateUser(final User user) {
        String mail = getEmail()
        if (StringUtils.isNotBlank(mail)) {
            user.getEmailAddresses()?.find { it.isDefault() }?.setEmailAddress(mail)
        }
        String first = attr('urn:oid:2.5.4.42')
        if (StringUtils.isNotBlank(first)) {
            user.setFirstName(first)
        }
    }
}
```

{% endcode %}

Confirm the `WORK_EMAIL` metadata type ID exists in your deployment, and confirm the user status value you choose against the user statuses in [Enumerated value reference](/administration-guide/reference/enums.md) — the point is to pick one deliberately, not to copy this one. Note where the rejections live: in `getPrincipal()`, `getFirstName()` and `getLastName()`, which run *inside* the creation sequence. Throwing there prevents the identity being built at all.

Register the script per [Create a Groovy script](/administration-guide/automation/manage-groovy-scripts.md).

## Attribute names differ by identity provider

{% hint style="danger" %}
**The `urn:oid:*` names above are right for a generic SAML identity provider and wrong for Entra ID.** Entra ID — formerly Azure AD, and the most common identity provider on this integration — sends WS-\* claim URIs instead, and its attribute values do not arrive as `XSString`. Put the script above in front of an Entra trust unchanged and every lookup misses: `getFirstName()` and `getLastName()` see null, and the first sign-in fails on your own guard. Correct the names but keep the `XSString` cast and it fails again, this time on a `ClassCastException` raised inside the creation sequence. Either way the person cannot sign in and no identity is created.
{% endhint %}

| What you are reading | Generic SAML IdP (LDAP / eduPerson OID names)  | Entra ID                                                             |
| -------------------- | ---------------------------------------------- | -------------------------------------------------------------------- |
| Given name           | `urn:oid:2.5.4.42`                             | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname`    |
| Surname              | `urn:oid:2.5.4.4`                              | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname`      |
| Email                | `urn:oid:0.9.2342.19200300.100.1.3`            | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` |
| How the value reads  | `XSString`, so `((XSString) value).getValue()` | generic XML content, so `getTextContent()`                           |

The shipped `TestJustInTimeSAMLAuthenticator_1.0.groovy` uses a third convention again — `urn:openiam:names:attribute:email`, cast to `XSString`. Three conventions in one product, and only the one your identity provider actually sends is correct for your deployment. Shibboleth, ADFS with the default claim rules, Okta, Google Workspace and a hand-rolled IdP will each differ; ADFS in particular sends the same `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/*` family as Entra.

Read both types rather than betting on one. This helper replaces `attr()` in the script above and works against either provider:

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```groovy
import org.opensaml.core.xml.XMLObject
import org.opensaml.core.xml.schema.XSAny
import org.opensaml.core.xml.schema.XSString
import org.opensaml.saml.saml2.core.Attribute
import org.apache.commons.collections4.CollectionUtils

    /** One attribute value, read whether the IdP typed it as xs:string or left it untyped. */
    private String text(final XMLObject value) {
        if (value instanceof XSString) {
            return ((XSString) value).getValue()
        }
        if (value instanceof XSAny) {
            return ((XSAny) value).getTextContent()
        }
        return null
    }

    /** First value of the named attribute, or null if the assertion does not carry it. */
    private String attr(final String name) {
        if (CollectionUtils.isEmpty(response.getAssertions())
                || CollectionUtils.isEmpty(response.getAssertions().get(0).getAttributeStatements())) {
            return null
        }
        for (final Attribute a : response.getAssertions().get(0).getAttributeStatements().get(0).getAttributes()) {
            if (name.equalsIgnoreCase(a.getName()) && CollectionUtils.isNotEmpty(a.getAttributeValues())) {
                return text(a.getAttributeValues().get(0))
            }
        }
        return null
    }
```

{% endcode %}

With that helper in place, an Entra script differs from the generic one only in three string constants, used in place of the `urn:oid:*` literals in `getFirstName()`, `getLastName()` and `getEmail()`:

{% code overflow="wrap" %}

```groovy
    private static final String CLAIM_GIVEN_NAME = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'
    private static final String CLAIM_SURNAME    = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'
    private static final String CLAIM_EMAIL      = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
```

{% endcode %}

{% hint style="info" %}
Field-proven Entra samples were written against OpenSAML 2 package names (`org.opensaml.saml2.core.Attribute`, `org.opensaml.xml.schema.impl.XSAnyImpl`). The examples here use the OpenSAML 3 names (`org.opensaml.saml.saml2.core.*`, `org.opensaml.core.xml.schema.*`), which match the `org.opensaml.saml.saml2.core.Response` the base class hands your script. The symbols are the same; the packages moved. Match whichever generation your IdP build ships, and verify by signing in: selecting the script on the service provider does not compile it, so a wrong import surfaces at the first sign-in rather than when you save.
{% endhint %}

### Discover the names your provider really sends

Do not guess, and do not copy a name out of somebody else's script. Three ways, best first:

1. **Read the identity provider's own claim configuration.** In Entra ID: **Enterprise applications →&#x20;*****your application*****&#x20;→ Single sign-on → Attributes & Claims**. That screen lists the exact claim names it will send and the source of each, which is the authoritative answer before a single sign-in has been attempted. It is also where you add a claim you find missing.
2. **Capture one assertion in a non-production tenant.** The browser POSTs `SAMLResponse` to OpenIAM's assertion consumer service as a base64 form field; take it from the browser's network tab or a SAML tracer extension and base64-decode it. A decoded assertion is PII and a bearer credential — decode it locally, do not paste it into a ticket, and do not add script logging that writes assertions to the IdP log.
3. **Ask the script for names only.** Attach this to a test trust, sign in once, and read the rejection message out of the IdP log. It reports attribute *names* and never values:

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```groovy
    /** Non-production only. Fails the sign-in with the attribute names this IdP actually sent. */
    private void reportAttributeNames() {
        final List<String> names = new ArrayList<>()
        if (CollectionUtils.isNotEmpty(response.getAssertions())
                && CollectionUtils.isNotEmpty(response.getAssertions().get(0).getAttributeStatements())) {
            for (final Attribute a : response.getAssertions().get(0).getAttributeStatements().get(0).getAttributes()) {
                names.add(a.getName())
            }
        }
        throw new SecurityException("JIT attribute discovery: ${names}")
    }
```

{% endcode %}

Call it as the first line of `getPrincipal()`, capture the names, then **remove it**. It refuses every sign-in by design, so no identity is created while you are discovering — which is exactly what you want during discovery, and unacceptable afterwards.

## Set the user type and initial role

`populate(user)` runs on create only, after the base class has set the names, the login and the email address, and it is where the created identity gets its shape:

{% code overflow="wrap" expandable="true" %}

```groovy
    private static final String USER_TYPE_ID   = 'EXTERNAL_PARTNER'       // a user type that exists in your deployment
    private static final String ACCESS_ROLE_ID = 'PARTNER_PORTAL_ACCESS'  // a role ID, not a role name

    @Override
    protected void populate(final User user) {
        user.setStatus(UserStatusEnum.PENDING_START_DATE)
        user.mdTypeId = USER_TYPE_ID
        if (StringUtils.isNotBlank(ACCESS_ROLE_ID)) {
            user.addRole(ACCESS_ROLE_ID)
        }
    }
```

{% endcode %}

* **`user.mdTypeId`** is the user type — the same metadata type an administrator picks on the create-user screen. It decides which page template, which conversion rules and which birthright logic the identity falls under, so setting it deliberately is how you get an **External Partner** identity rather than a default employee. Create the type first and confirm its ID; see [Convert a user's type](/administration-guide/identities/convert-a-user-type.md).
* **`user.addRole(...)`** takes a role **ID**. If you only know the name, resolve it with `getRoleIdByName` below rather than hard-coding an ID that will differ between environments. Add one narrow, non-privileged role.

{% hint style="warning" %}
Remember which side of the provisioning switch you are on. With provisioning enabled, a role added here fires business rules, policy maps and connectors *inside the authentication request*, so a role with birthright provisioning creates real accounts in real managed systems on somebody's first sign-in. Pick a role that does nothing expensive, and remember the role is granted **before** the entitlement check decides whether this person may sign in at all.
{% endhint %}

## Map identity-provider groups to OpenIAM groups

This is the most useful and the most dangerous thing a JIT script does. The governance rules are on [Safe attribute handling](/administration-guide/federation/jit-provisioning.md#safe-attribute-handling) and nothing here softens them: map from an explicit allow-list, never from the claim, and keep administrative groups and roles out of the mapping entirely.

The mechanics rest on one pattern: **a JIT script reaches OpenIAM services by injecting them by bean name.** The base class already gives you `accessRightWebService` and `metadataCache`; anything else you declare yourself, and `@Resource(name = "...")` with the bean's name is the documented way to do it:

{% code overflow="wrap" %}

```groovy
import javax.annotation.Resource

import org.openiam.srvc.am.GroupDataWebService
import org.openiam.srvc.am.RoleDataWebService

    @Resource(name = "groupServiceClient")
    protected GroupDataWebService groupServiceClient

    @Resource(name = "roleServiceClient")
    protected RoleDataWebService roleServiceClient
```

{% endcode %}

Then read the claim, filter it against an allow-list, and resolve each surviving name to an ID with an exact-match search scoped to the managed system that holds those groups:

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```groovy
import org.openiam.base.response.list.GroupListResponse
import org.openiam.base.response.list.RoleListResponse
import org.openiam.base.ws.MatchType
import org.openiam.base.ws.SearchParam
import org.openiam.idm.searchbeans.GroupSearchBean
import org.openiam.idm.searchbeans.RoleSearchBean

    /** The claim carrying group membership. Read it off your IdP, do not assume it. */
    private static final String GROUPS_CLAIM = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/groups'

    /** The managed system whose groups these are. */
    private static final String GROUPS_MANAGED_SYS_ID = 'PARTNER_IDP'

    /** The only groups this trust may ever grant. Everything else in the claim is ignored. */
    private static final Set<String> ALLOWED_GROUPS =
            ['Partner-Portal-Read', 'Partner-Portal-Submit'] as Set

    /** Every value of the groups claim. It is multi-valued — do not take get(0). */
    private List<String> getGroupsFromResponse() {
        final List<String> names = new ArrayList<>()
        if (CollectionUtils.isEmpty(response.getAssertions())
                || CollectionUtils.isEmpty(response.getAssertions().get(0).getAttributeStatements())) {
            return names
        }
        for (final Attribute a : response.getAssertions().get(0).getAttributeStatements().get(0).getAttributes()) {
            if (GROUPS_CLAIM.equalsIgnoreCase(a.getName())) {
                for (final XMLObject value : a.getAttributeValues()) {
                    final String claimed = text(value)
                    if (StringUtils.isNotBlank(claimed)) {
                        names.add(claimed)
                    }
                }
            }
        }
        return names
    }

    private String getGroupIdByName(final String name, final String mngSysId) {
        if (StringUtils.isBlank(name)) {
            return null
        }
        final GroupSearchBean searchBean = new GroupSearchBean()
        searchBean.setNameToken(new SearchParam(name, MatchType.EXACT))
        searchBean.setManagedSysId(mngSysId)
        final GroupListResponse found = groupServiceClient.findBeans(searchBean, null, 0, 1)
        if (found != null && CollectionUtils.isNotEmpty(found.getList())) {
            return found.getList().get(0).getId()
        }
        return null
    }

    private String getRoleIdByName(final String name, final String mngSysId) {
        if (StringUtils.isBlank(name)) {
            return null
        }
        final RoleSearchBean searchBean = new RoleSearchBean()
        searchBean.setNameToken(new SearchParam(name, MatchType.EXACT))
        searchBean.setManagedSysId(mngSysId)
        final RoleListResponse found = roleServiceClient.findBeans(searchBean, null, 0, 1)
        if (found != null && CollectionUtils.isNotEmpty(found.getList())) {
            return found.getList().get(0).getId()
        }
        return null
    }
```

{% endcode %}

Call it from `populate(user)`, so mapping only ever happens on create:

{% code overflow="wrap" %}

```groovy
        getGroupsFromResponse().findAll { ALLOWED_GROUPS.contains(it) }.each { String claimed ->
            final String groupId = getGroupIdByName(claimed, GROUPS_MANAGED_SYS_ID)
            if (StringUtils.isNotBlank(groupId)) {
                user.addGroup(groupId)
            }
        }
```

{% endcode %}

Four details decide whether this works at all:

* `SearchParam(name, MatchType.EXACT)` is an **exact** match. A trailing space, a punctuation difference, or a display name asserted where OpenIAM stores a distinguished name all miss, and `getGroupIdByName` returns null. Nothing throws — the group is simply not granted, which is a silent failure you have to test for rather than wait to be told about.
* `setManagedSysId(...)` scopes the search, which is what keeps a partner group named `Administrators` from resolving to an internal group of the same name. Set it.
* Entra ID's groups claim emits group **object IDs**, not names, unless the claim is configured to send names. An exact-match search on a GUID matches nothing, so either configure the claim to send names or keep your own explicit map from object ID to OpenIAM group.
* `getRoleIdByName` is the identical shape for roles, and `user.addRole(id)` takes its result. Use it to resolve the access role by name instead of hard-coding an environment-specific ID.

## Related

* [Just-in-time provisioning](/administration-guide/federation/jit-provisioning.md) — the creation order, the tests and the rollback
* [Create a Groovy script](/administration-guide/automation/manage-groovy-scripts.md) — registering and promoting the script
* [Enumerated value reference](/administration-guide/reference/enums.md) — user statuses and metadata type groupings

*Applies to OpenIAM 2026.8.1. Last reviewed 7 August 2026.*


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.openiam.com/administration-guide/federation/jit-provisioning/jit-provisioning-reference.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
