Context
As a company I want to centrally manage users and user access for the applications in my application landscape. One of these applications will be Content Hub.
In order to achieve this I want to enable SSO login on Content Hub to verify the user’s identity on my IDP of choice. Additionally I would like to control the user access level based on the user Claims received after a successful login into my IDP.
For this recipe we are using Microsoft Entra ID, previous called Microsoft Azure AD.
This recipe focuses on authentication through Content Hub, if you are using Sitecore Cloud Portal please review the Cloud Portal documentation.
Execution
Setting up the Single-Sign-On
An new enterprise application should be provisioned on Microsoft Entra ID. It is important to take note on the following details when configuring the Enterprise application.
Key | Detail |
---|---|
Allowed redirect url’s | The allowed redirect url should be configured as following to ensure a post back to Content Hub after login is allowed. Where contenthubinstance is your Content Hub URL - https://[contenthubinstance]/AuthServices-saml/Acs |
Token configuration (SAML Claims) | The following SAML Claims should be configured in order to fulfill the requirements in this recipe - Email and Security Groups. |
On Microsoft Entra ID the number of groups emitted in a token is limited to 150 for SAML. For companies having a lot of security groups, this can be an issue as groups claims will be totally omitted for users having a membership in 150+ groups. As a workaround use user group assignment on your application if possible or apply a group filter as described here.
Navigate to the Content Hub settings page as a super user (Manage > Settings). Under Portal Configuration you can find the Authentication setting. As we are using SAML, we will need to setup the SAML provider, with the following details.
Key | Detail |
---|---|
email_claim_type | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress. Unless configured differently on Microsoft Entra ID, this is the default for email addresses. |
idp_entity_id | This can be found on the metadataxml generated by Microsoft Entra ID (entityID on the root node of the document) |
metadata_location | The link towards your metadata xml as provided by your enterprise application on Microsoft Entra ID. |
provider_name | SAML (default) |
sp_entity_id | As configured on Azure Entra ID (Application ID URI) |
username_claim_type | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name. Unless configured differently on Microsoft Entra ID, this is the default for username. |
"ExternalAuthenticationProviders": { "global_username_claim_type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "global_email_claim_type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "google": [], "microsoft": [], "saml": [ { "authentication_mode": "Passive", "email_claim_type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "idp_entity_id": "url to be found on metadataxml (entityID on root node)", "is_enabled": true, "metadata_location": "https://login.microsoftonline.com/link-to-your-metadataxml", "provider_name": "SAML", "sp_entity_id": "as configured on Azure Entra ID", "username_claim_type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "messages": { "signIn": "Sign in with SSO" } } ], "sitecore": [], "ws_federation": [], "yandex": [] },
As a first test, you can now try to login into Content Hub with SSO - Users are automatically created on first login.
You can bypass a Active configuration by forcing passive mode by appending the following query string. https://contenthubinstance/en-US/Account?forcePassive=true. This might be useful if you still like to login as regular user into Content Hub. eg.: to perform administrative tasks as a Super User.
In order to see what has been transmitted as a Claim it is good to have a SAML tracer extension on your browser. eg: SAML, WS-Federation and OAuth 2.0 tracer
Automated Claim to Content Hub Security Groups mapping
As we want to centrally manage security group membership, Microsoft Entra ID is a good place to do this. However, we do also want this being reflected into our Content Hub Application. In order to do this we will need to read-out any group Claims that will be emitted at login and do some AD group to Content Hub mapping magic.
The two necessary parts to make this working on Content Hub are:
- A setting to configure the desired group mapping
- A sign-in script that reads out the group claims at login and applies the mapping so the user has the appropriate security rights after login.
We could just go ahead and create a new setting on the Content Hub from the interface (Manage > Settings).
However, you might have multiple Content Hub environments (DEV, UAT, PROD) and it might be required to have a different mapping configurations per environment as the AD groups are potentially different for each environment. In order to create a environment specific setting we will need to do a POST request to create to entity with your tool of choice (eg.: POSTMAN). This is not possible through the interface as M.Setting.EnvironmentSpecific
is marked as set-once.
Endpoint: https://contenthubinstance/api/entities
Method: POST
Body:
{ "identifier": "M.Setting.SSOMapping", "properties": { "M.Setting.Name": "SSOMapping", "M.Setting.EnvironmentSpecific": true, "M.Setting.Value": {} }, "relations": { "SettingCategoryToSettings": { "parent": { "href": "https://[contenthubinstance]/api/entities/759" } } }, "entitydefinition": { "href": "https://[contenthubinstance]/api/entitydefinitions/M.Setting" } }
M.Setting.EnvironmentSpecific
has been set to true at creation time as this is the only moment where we can set this property.
Once you have executed the POST command, you should have a new setting available under Security > SSOMapping. Now it is a good time to populate the mapping setting with some default values.
Example:
{ "groupMapping": [ { "label": "SuperUser mapping", "external": "2db23085-7778-4d35-b2bb-3869fbc47552", "internalGroups": [ "superuser" ] } ], "claimType": "http://schemas.microsoft.com/ws/2008/06/identity/claims/groups", "defaultGroups": [ "Everyone" ] }
The external property indicates the external group that comes in through the Claims emitted by Entra ID, the internal property indicates to which Content Hub usergroup it will be mapped to.
The only thing left to make our automated mapping working is to add a user sign-in script. Navigate to Scripts under the Manage section and add a new sign-in script.
Next copy the following mapping script in your User sign-in script. Build, Publish and enable the script.
using System.Linq;
using Stylelabs.M.Sdk;
using Stylelabs.M.Scripting.Types.V1_0.User;
using Stylelabs.M.Scripting.Types.V1_0.User.SignIn;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System.Threading.Tasks;
const string ROLES_MAP_SETTING_CATEGORY = "Security";
const string ROLES_MAP_SETTING_NAME = "SSOMapping";
class GroupMapping {
public string label {get;set; }
public string external {get; set;}
public string[] internalGroups {get; set;}
}
class MappingSetting {
public List<GroupMapping> groupMapping {get;set;}
public string ClaimType {get;set;}
public List<string> defaultGroups {get;set;}
}
try {
await RunScriptAsync();
}
catch(Exception ex)
{
MClient.Logger.Error(ex);
}
public async Task RunScriptAsync() {
if(Context.AuthenticationSource == AuthenticationSource.Internal) {
MClient.Logger.Info($"Authentication source is Internal. Exiting.");
return;
}
MClient.Logger.Info($"Script called for user {Context.User.Id}");
MClient.Logger.Debug($"User claims: {string.Join(",\r\n", Context.ExternalUserInfo.Claims.Select(c => $"{c.Type}: {c.Value}"))}");
var roleMapSettings = await GetSettingValueAsync<JObject>(ROLES_MAP_SETTING_CATEGORY, ROLES_MAP_SETTING_NAME);
if(roleMapSettings == null) {
MClient.Logger.Error($"Could not load role map settings. Exiting.");
return;
}
var mappingSetting = roleMapSettings.ToObject<MappingSetting>();
var groupClaims = GetClaimValues(mappingSetting.ClaimType);
if(!groupClaims.Any()) {
MClient.Logger.Info($"No claims of type {mappingSetting.ClaimType}. Exiting.");
return;
}
MClient.Logger.Debug($"Found {groupClaims.Count()} claim(s). {string.Join(",\r\n", groupClaims)}");
var groupMaps = mappingSetting.groupMapping.Where(m => groupClaims.Contains(m.external));
MClient.Logger.Info($"Found {groupMaps.Count()} group map(s). For roles\r\n{string.Join(", ", groupMaps.Select(rm => rm.external))}");
var userGroupNames = groupMaps.SelectMany(rm => rm.internalGroups).ToList();
MClient.Logger.Info($"Found {userGroupNames.Count()} user group(s).\r\n{string.Join(", ", userGroupNames)}");
//Adding the default usergroups to the list
userGroupNames.AddRange(mappingSetting.defaultGroups);
var groupIds = await GetIdsForGroupNamesAsync(userGroupNames);
await AddUserGroupsToUserAsync(groupIds.Distinct());
}
#region functions
public IEnumerable<string> GetClaimValues(string type) {
return Context.ExternalUserInfo.Claims.Where(c => c.Type == type).Select(c => c.Value);
}
public async Task<T> GetSettingValueAsync<T>(string category, string name) {
var settingEntity = await MClient.Settings.GetSettingAsync(category, name).ConfigureAwait(false);
return settingEntity.GetPropertyValue<T>("M.Setting.Value");
}
public async Task<IEnumerable<long>> GetIdsForGroupNamesAsync(IEnumerable<string> groupNames) {
var query = Query.CreateQuery(entities =>
from e in entities
where e.DefinitionName == "Usergroup" && e.Property("GroupName").In(groupNames)
select e);
var queryResult = await MClient.Querying.QueryIdsAsync(query);
return queryResult.Items;
}
public async Task AddUserGroupsToUserAsync(IEnumerable<long> userGroupIds) {
var userGroupToUserRelation = await Context.User.GetRelationAsync<IChildToManyParentsRelation>("UserGroupToUser");
//REPLACE the usergroups as per mapping result
//Update usergroup to user relation
userGroupToUserRelation.SetIds(userGroupIds);
//Update usergroup configuration on the user.
var userGroupConfigurationProperty = Context.User.GetPropertyValue<JToken>("UserGroupConfiguration");
if(userGroupConfigurationProperty == null){
Context.User.SetPropertyValue("UserGroupConfiguration",JToken.Parse("{\"combine_method\": \"Any\",\"user_group_ids\": [],\"children\": []}"));
userGroupConfigurationProperty = Context.User.GetPropertyValue<JToken>("UserGroupConfiguration");
}
if (userGroupConfigurationProperty != null)
{
var jGroupsIds = JToken.FromObject(userGroupIds);
MClient.Logger.Info($"Applying following user groups: {jGroupsIds} for user with id {Context.User.Id}");
userGroupConfigurationProperty.SelectToken("user_group_ids")?.Replace(jGroupsIds);
}
else
{
MClient.Logger.Info("UserGroupConfiguration property is null somehow");
}
await MClient.Entities.SaveAsync(Context.User);
}
#endregion
If everything works as expected, users that login into Content Hub should be automatically assigned to the appropriate user groups, based on group information emitted by your IDP. Important to note here is that the script will adjust your membership situation each time a login is performed, this includes security group movements as well.