enhancement
major
minor
defect
critical
major
minor
major
#30172
Create accounts at an external login and assign their groups from a claim
Problem
A login through an external authentication system (pac4j/OIDC) only finds an existing account: DefaultExternalUserMapping resolves the login name to a Person and refuses an unknown one. An application that wants its users to sign up through the identity provider has to create every account in advance and keep its group memberships in sync by hand.
The data is already there: ExternalUserMapping.findAccountForIdentity(ExternalIdentity) receives the subject, the login name and all claims of the login.
Design
A configurable ExternalUserMapping (usable as user-mapping of any pac4j client) that provisions accounts just in time:
- Lookup: the account linked to the subject of the identity (stable per client), otherwise the account of the login name.
- Creation (switchable): if no account exists, one is created. Login name, first name, last name and e-mail are taken from configurable claims (defaults preferred_username, given_name, family_name, email). Creation can be restricted to a confirmed e-mail address (email_verified) and to a set of allowed e-mail domains. A refused creation is a LoginDeniedException with a message the login page shows.
- Groups: a configurable claim (e.g. groups of Keycloak, groups/`roles` of Entra ID) carries group names. A mapping table translates claim values to `Group`s of the application. Unmapped values are ignored, or optionally taken over as the group of the same name if it exists. A configurable default group is assigned to an account created without a group claim.
- Synchronization at every login: the identity provider is authoritative for the groups named in the mapping (and the default group); they are added and removed at each login. Groups assigned in the application by hand are not touched.
- The created account has no password; it logs in only through the external system.
- Deleting accounts the identity provider no longer knows is not part of it (needs a push channel such as SCIM).
Acceptance
- A login of an unknown identity with creation switched on creates the account with the names and e-mail of the claims and the mapped groups; a second login finds the same account by subject even after a changed login name.
- A login whose group claim lost a mapped group removes that membership; a hand-assigned group stays.
- Creation switched off, unconfirmed e-mail or a foreign domain: the login is refused with a visible reason, no account is created.
- An identity without group claim gets the default group.
Needed by the control application tl-factory, whose built-in OpenID provider for the test users of an application under development issues these claims.
Lösung
Module com.top_logic.security.auth.pac4j (now depends on tl-element); article authentication/account-provisioning.
Identity of an account
- Model type OidcIdentity (module tl.security.pac4j: client, subject, grantedGroups) with a unique index on (client, subject), attached to tl.accounts:Person as the multiple, read-only composition oidcIdentities. An account can carry identities of several clients.
Provisioning mapping `OidcProvisioningUserMapping`
An ExternalUserMapping for the user-mapping of a pac4j client. Lookup order:
- the account with an OidcIdentity of (client, subject);
- with link-by-email (default false, only for providers whose e-mail verification is trusted): the single account with the e-mail of the identity, if the claim email_verified is true;
- with link-by-name (default true): the account of the login name;
- with create-accounts (default false): a new account; otherwise the login is refused as unknown account.
An account found in step 2 or 3 gets the identity attached. If it already carries another identity of the same client, the login is refused.
- Creation: login name, first name, last name and e-mail from configurable claims (defaults preferred_username, given_name, family_name, email); optionally require-verified-email and allowed-email-domains. The account gets the authentication device authentication-device (default none: it logs in only externally).
- E-mail: the e-mail of an account is that of its contact (Person.getUser()), so link-by-email and the e-mail of a created account need tl-contact; without it only first and last name are written.
- Name conflict (the login name of a new account is taken by an account that is not linked), setting name-conflict: refuse (default) refuses the login with a visible reason; rename creates the account under <name>@<client> (then <name>@<client>-2, ...). A conflict is never linked silently.
- Groups: group-claim carries group names; group-mapping translates a claim value to one or more groups; groups-by-name takes an unmapped value as the existing group of the same name; default-groups apply while the claim is missing. The OidcIdentity records which memberships it has granted: at each login, groups the claim yields now are added, groups it granted before and no longer yields are removed, unless another identity of the account also granted them. Memberships that existed before the identity yielded the group are never touched. The provider is authoritative for the groups it yields: a granted membership removed by hand comes back at the next login.
- Listeners: the Java interface ExternalLoginListener (in com.top_logic.base.accesscontrol) with onCreate(Person account, ExternalIdentity identity) (only for a created account, e.g. create objects and link the account to new or existing objects, so that role rules give it roles right away) and onLogin(Person account, ExternalIdentity identity) (at every login). The mapping calls the configured listeners in the transaction of the login after the group synchronization. A failing listener rolls the transaction back and refuses the login.
- Listener in TL-Script: ExternalLoginListenerByExpression (tl-model-search, tag login-script) runs the optional scripts on-create and on-login, each a function account -> claims -> client -> ... (the account, the claims of the identity as dictionary, the client name). The scripts run without security check (no user is logged in yet) and may modify the account, its contact and other objects; they are compiled at their first use. A script failure refuses the login, a message raised with throw(...) is shown as the reason. This allows an application to work around wrong data of an identity provider by configuration, e.g. a given_name delivered as Lastname, Firstname:
{{{#!xml <listeners>
<login-script>
<on-login><![CDATA[
account -> claims -> client ->
$account.get(tl.accounts:Person#contact)
.set(Contacts:PersonContact#firstname,
regex("[,]*, *").regexReplace($claims["given_name"], ""))
]]></on-login>
</login-script>
</listeners> }}}
- Transactions: a login that finds its account by subject, changes no group and has no listeners opens no transaction; otherwise resolution, group synchronization and listeners run in one transaction.
- Refusals: Login.LoginDeniedException carries an optional reason (ResKey); ExternalAuthenticationServlet shows it on the login retry page instead of the general message.
- An account without authentication device is supported by the password dialogs; ForgotPasswordDialog (tl-security-selfservice) treats it like an account whose password cannot be changed.
Configuration of extractor and mapping
The properties user-name-extractor and user-mapping of a pac4j client (ClientConfigurator.Config) and user-mapping of J2eeRemoteUserAuthenticationServlet.Config are polymorphic configurations instead of configured instances, so that they can be edited in the service editor. Pac4jConfigFactory creates extractor and mapping once per client; a re-authentication client uses those of the client it is derived from.
Out of scope: deleting accounts the provider no longer knows (needs SCIM); explicitly connecting a further login to one's own account in the profile: #30178.
Migration
The configuration properties user-name-extractor and user-mapping (pac4j clients in Pac4jConfigFactory, J2eeRemoteUserAuthenticationServlet) no longer accept a bare class name as attribute value. Replace {{{#!xml <keycloak name="..." user-mapping="com.example.MyMapping"> }}} by the element form {{{#!xml <keycloak name="...">
<user-mapping class="com.example.MyMapping"/>
</keycloak> }}} The element form <user-mapping class="..." .../> with further attributes stays unchanged. Java code reading getUserMapping() / getUserNameExtractor() from the client configuration obtains the instances from Pac4jConfigFactory.getUserMapping(String) / getUserNameExtractor(String) instead.