Microsoft Azure Active Directory custom group authentication
This tutorial is based on and tested with Magnolia SSO module 3.1.3 and Magnolia CMS 6.2.33.
An update to to ensure it works with Magnolia SSO module 4.0.x and Magnolia CMS 6.3.0 is planned.
You cannot use the same configuration with SSO 2.x, but the logic of group resolution in combination with Azure AD is the same.
You must have a DX Core license to be able to follow this tutorial.
In Azure, it’s possible to configure application roles, but this is not covered in this tutorial.
If you want to get rid of the role mapping requirement in the SSO YAML configuration file, you can use the solution in this tutorial for other identity providers using your own custom classes.
This tutorial assumes you are an advanced user of Magnolia and familiar with Azure AD and SSO.
This tutorial shows you how to use a custom authentication class with Magnolia SSO and Microsoft Azure Active Directory (Azure AD).
You may decide to use this approach because:
You want to manage users, groups, and permissions using only Azure AD.
You don’t want to deal with cryptic IDs instead of group/role names. Azure AD doesn’t put names in the token payload by default, only IDs.
Permissions management in your environment is complex and/or flexible.
People administering Azure AD cannot (or should not) work with YAMl files in a Magnolia installation.
How Azure AD delivers group information
Group information in the token payload
You can configure Azure AD to deliver group information within the payload of the OpenID Connect token that is delivered to Magnolia after a user has successfully authenticated.
The Azure AD group information delivered in the token payload looks like this:
You can map the group IDs in a Magnolia SSO YAML configuration file to apply the required permissions.
This is explained on the main SSO module documentation page.
No group information
If there is no group information available in the token data, you can use Microsoft Graph to query group information once users have obtained the required bearer token.
You still need to provide the appropriate security configuration in Azure AD.
We recommend having some sort of fallback configuration so that you can see that your project is running and your custom provider is being invoked.
Magnolia SSO configuration cannot be applied to to all providers in the same way or using the same syntax.
The example below assumes you have superuser access.
Adapt this configuration to your setup.
This example runs under the /magnoliaAuthor context.
The Azure AD values shown are are fake.
Basic config.yaml example for Azure AD
# tested with SSO 3.1.3callbackUrl:/.authpostLogoutRedirectUri:http://localhost:8080authorizationGenerators:-name:fixedRoleAuthorizationfixed:targetRoles:-superuser-name:dummyAuthorizationGeneratorclients:oidc.name:defaultOidcClientoidc.id:7b5bbb6c-f71f-52e4-b646-d3b332a1c10eoidc.secret:TXm9Q~s_tS2iILqPzYm~jqwaGUIQoxyphGKrecipoidc.clientAuthenticationMethod:client_secret_post# use your tenant from Azure AD - you need the value later for the custom provider classoidc.discoveryUri:https://login.microsoftonline.com/f7c33569-d9fg-87e6-a2af-3e4feq02310c/v2.0/.well-known/openid-configurationoidc.authorizationGenerators:fixedRoleAuthorization,dummyAuthorizationGenerator,CustomAzureAdAuthorizationGeneratorProvideruserFieldMappings:name:preferred_usernameremoveEmailDomainFromUserName:trueremoveSpecialCharactersFromUserName:falsefullName:nameemail:emaillanguage:localeCopy
Troubleshooting tips
Build and launch your project, open Magnolia AdminCentral.
Log in with an Azure AD user account, the login should work with superuser privileges.
In the Magnolia log file, you should see the log message you put in the skeleton of our custom authentication generator class.
If Magnolia doesn’t start and tells you that something is wrong with your module, there is most likely a problem with the syntax of config.yaml file.
Add logic to your custom authentication
Custom authentication generator logic
The generate method checks for a groups attribute in the payload of the OIDC token.
The groups attribute is found: Take the array of group IDs and resolve the names using MS Graph and the authenticated user’s bearer token.
No groups attribute: MS Graph and the bearer token are used to query the groups owned by the authenticated user.
All resolved group names are added to the PAC4J OIDC profile under the name mgnlGroups.
After that, the normal flow of the Magnolia SSO module continues: retrieving the groups from the profile, matching them to the ones in Magnolia, and applying permissions.
With this approach, loading roles from groups in Magnolia works, and no manual role mapping in config.yaml is necessary.
Azure AD custom authentication class example
You must replace the value for private final static String tenant with your own tenant ID (or name).
This is very important.
Expand the collapsed content to see the full sample file.
CustomAzureAdAuthorizationGenerator.java
package info.magnolia.sso.auth.azure;
import info.magnolia.objectfactory.Components;
import info.magnolia.sso.config.Pac4jConfigProvider;
import net.minidev.json.JSONArray;
import net.minidev.json.JSONObject;
import net.minidev.json.JSONValue;
import org.pac4j.core.authorization.generator.AuthorizationGenerator;
import org.pac4j.core.config.Config;
import org.pac4j.core.context.HttpConstants;
import org.pac4j.core.context.WebContext;
import org.pac4j.core.context.session.SessionStore;
import org.pac4j.core.profile.UserProfile;
import org.pac4j.core.util.HttpUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.*;
import org.apache.commons.lang3.StringUtils;
import org.pac4j.oidc.profile.OidcProfileDefinition;
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
importstatic java.util.function.Predicate.not;
importstatic java.util.stream.Collectors.toList;
publicclassCustomAzureAdAuthorizationGeneratorimplementsAuthorizationGenerator{
privatestaticfinal Logger log = LoggerFactory.getLogger(CustomAzureAdAuthorizationGenerator.class);
// if the user groups property is delivered with the token payload, it usually contains the UIDs only, not the namesprivatestaticfinal String GROUPS_PROPERTY = "groups";
// used to resolve groups in SSOAuthenticationModuleprivatestaticfinal String MGNL_GROUPS_ATTR_NAME = "mgnlGroups";
// property for resolving group memberships when there is no group or role attribute in the token payloadprivatestaticfinal String AZURE_USER_GROUPS_SERVICE_URL = "https://graph.microsoft.com/v1.0/me/memberOf";
// properties for resolving group UIDs// https://graph.microsoft.com/v1.0/<tenant>/groups/<group-uid>privatestaticfinal String AZURE_GROUP_ID_PREFIX = "https://graph.microsoft.com/v1.0/";
privatestaticfinal String AZURE_GROUP_ID_SUFFIX = "/groups/";
// common propertiesprivatefinalstatic String MS_GRAPH_VALUE_PROPERTY = "value";
privatefinalstatic String MS_GRAPH_GROUP_NAME_KEY = "displayName";
// TODO Replace with your own tenant ID!privatefinalstatic String tenant = "f7c33569-d9fg-87e6-a2af-3e4feq02310c"; (1)@Overridepublic Optional<UserProfile> generate(WebContext context, SessionStore sessionStore, UserProfile profile){
log.debug("CustomAzureAdAuthorizationGenerator - generate ...");
List<String> userGroups = new ArrayList<>();
try {
Optional<Object> groupsAttribute = Optional.ofNullable(profile.getAttribute(GROUPS_PROPERTY));
// take the access token from the logged-in user// it is needed to query MS Graph APIs
BearerAccessToken bearerAccessToken = ((BearerAccessToken) profile.getAttribute(OidcProfileDefinition.ACCESS_TOKEN));
String bearerToken = StringUtils.defaultString(bearerAccessToken.getValue(), "");
// in production, do not show this in the logs
log.debug("Bearer token: {}", bearerToken);
if (groupsAttribute.isEmpty()) {
// there is no roles and no groups element in the token payload, just general user data// we will query MS graph for the user's group memberships
log.debug("There is no groups/roles claim in the token payload. Group memberships will be queried from MS Graph.");
JSONObject queryResult = queryMsGraph(bearerToken, AZURE_USER_GROUPS_SERVICE_URL);
log.debug("Query result: {}", queryResult);
// parse the result and add group names to the user's group list
userGroups = getAzureUserGroups(queryResult);
} else {
// the groups claim is present in the token payload, but it does only contain a list of the group UIDs, not the names
log.debug("The groups claim with group UIDs is present in the token payload. Group names will be queried from MS Graph.");
Object groupsAttributeValue = groupsAttribute.get();
log.debug("Attempting to gather authorization with {} property value: {}", GROUPS_PROPERTY, groupsAttributeValue);
List<String> groupIDs = new ArrayList<>();
if (groupsAttributeValue instanceof ArrayList) {
// parse a json array using their parent type ArrayList
List<Object> jsonArray = (ArrayList) groupsAttributeValue;
groupIDs.addAll(jsonArray.stream()
.map(Object::toString)
.filter(not(String::isEmpty))
.collect(toList()));
} elseif (groupsAttributeValue instanceof String) {
// parse a comma-separated list of groups?
groupIDs.addAll(Arrays.stream(((String) groupsAttributeValue).split(","))
.filter(not(String::isEmpty))
.collect(toList()));
} else {
log.error("Could not parse groups from user profile: unexpected \"{}\" attribute type: {} ({})", GROUPS_PROPERTY, groupsAttributeValue, groupsAttributeValue.getClass().getName());
return Optional.of(profile);
}
log.debug("Group UIDs: {}", groupIDs);
getAzureAdGroupNamesFromId(bearerToken, tenant, groupIDs, userGroups);
}
log.debug("Resulting group list for the logged in user: {}", userGroups);
if (!userGroups.isEmpty()) {
profile.addAttribute(MGNL_GROUPS_ATTR_NAME, userGroups);
}
} catch (Exception e) {
log.info("An attempt to retrieve roles or groups for the logged in user failed.", e);
}
log.debug("User profile after assigning group membership: {}", profile);
return Optional.of(profile);
}
/**
* Parse group display names from the MS graph query result.
*
* @param jsonObject Query result as JSON object.
* @return List with user's group names or empty list.
*/protectedstatic List<String> getAzureUserGroups(JSONObject jsonObject){
List<String> userGroupList = new ArrayList<>();
Optional<Object> groupArray = Optional.ofNullable(jsonObject.get(MS_GRAPH_VALUE_PROPERTY));
if (groupArray.isEmpty()) {
log.error("Could not find the value where the groups are contained in the JSON object.");
} else {
JSONArray jsonArray = (JSONArray) groupArray.get();
jsonArray.forEach(element -> {
JSONObject obj = (JSONObject) element;
String displayName = StringUtils.defaultString((String) obj.get(MS_GRAPH_GROUP_NAME_KEY), "");
if (StringUtils.isNotBlank(displayName)) {
log.debug("Fetched group with name {}.", displayName);
if (StringUtils.isNotBlank(displayName)) {
userGroupList.add(displayName);
}
}
});
}
return userGroupList;
}
/**
* Resolve group names from a list with group UIDs retrieved in the token payload.
*
* @param bearerToken Access token.
* @param tenant Azure AD tenant provided in config.yaml.
* @param groupIdList Groups attribute from the token payload containing group UIDs.
* @param groupList Resolved group names are added to the resulting group list.
*/protectedstaticvoidgetAzureAdGroupNamesFromId(String bearerToken, String tenant, List<String> groupIdList, List<String> groupList){
String msGraphUrl = AZURE_GROUP_ID_PREFIX + tenant + AZURE_GROUP_ID_SUFFIX;
for (String groupId : groupIdList) {
log.debug("Trying to resolve group with ID {}.", groupId);
// GET https://graph.microsoft.com/v1.0/<tenant>/groups/<group-uid>
String queryUrl = msGraphUrl + groupId;
log.debug("Querying resource {}", queryUrl);
JSONObject queryResult = queryMsGraph(bearerToken, queryUrl);
log.debug("Query result: {}", queryResult);
String displayName = StringUtils.defaultString((String) queryResult.get(MS_GRAPH_GROUP_NAME_KEY), "");
if (StringUtils.isNotBlank(displayName)) {
log.debug("Fetched group with name {}.", displayName);
if (StringUtils.isNotBlank(displayName)) {
groupList.add(displayName);
}
}
}
}
/**
* Query MS Graph URL using the access token we retrieved after a successful login.
*
* @param bearerToken Access token.
* @param msGraphUrl URL to query.
* @return The response retrieved from Azure as JSON object.
*/privatestatic JSONObject queryMsGraph(String bearerToken, String msGraphUrl){
JSONObject jsonObjectResult = new JSONObject();
HttpURLConnection connection = null;
final Map<String, String> headers = new HashMap<>();
headers.put(HttpConstants.ACCEPT_HEADER, HttpConstants.APPLICATION_JSON);
headers.put(HttpConstants.AUTHORIZATION_HEADER, HttpConstants.BEARER_HEADER_PREFIX + bearerToken);
try {
connection = openConnection(new URL(msGraphUrl), HttpConstants.HTTP_METHOD.GET.name(), headers);
finalint responseCode = connection.getResponseCode();
log.debug("Response Code received: {}", responseCode);
String body = HttpUtils.readBody(connection);
log.debug("Response body: {}", body);
log.debug(body);
jsonObjectResult = (JSONObject) JSONValue.parse(body);
return jsonObjectResult;
} catch (Exception e) {
log.error("Problem while querying MS Graph: ", e);
} finally {
HttpUtils.closeConnection(connection);
}
return jsonObjectResult;
}
// method taken from the PAC4J HttpUtils class (has protected access)protectedstatic HttpURLConnection openConnection(final URL url, final String requestMethod, final Map<String, String> headers)throws IOException {
final HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setDoInput(true);
connection.setDoOutput(true);
connection.setRequestMethod(requestMethod);
connection.setConnectTimeout(HttpConstants.DEFAULT_CONNECT_TIMEOUT);
connection.setReadTimeout(HttpConstants.DEFAULT_READ_TIMEOUT);
if (headers != null) {
for (final Map.Entry<String, String> entry : headers.entrySet()) {
connection.setRequestProperty(entry.getKey(), entry.getValue());
}
}
return connection;
}
}Copy
1
Replace the value for private final static String tenant with your own tenant ID (or name).
This is very important.
Magnolia SSO
Leave the config.yaml as is and check the log files.
If everything works as expected, you can remove the fixedRoleAuthorization:
config.yaml
# tested with SSO 3.1.3callbackUrl:/.authpostLogoutRedirectUri:http://localhost:8080authorizationGenerators:(1)-name:dummyAuthorizationGeneratorclients:oidc.name:defaultOidcClientoidc.id:7b5bbb6c-f71f-52e4-b646-d3b332a1c10eoidc.secret:TXm9Q~s_tS2iILqPzYm~jqwaGUIQoxyphGKrecipoidc.clientAuthenticationMethod:client_secret_post# use your tenant from Azure ADoidc.discoveryUri:https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration(2)oidc.authorizationGenerators:dummyAuthorizationGenerator,CustomAzureAdAuthorizationGeneratorProvider(1)userFieldMappings:name:preferred_usernameremoveEmailDomainFromUserName:trueremoveSpecialCharactersFromUserName:falsefullName:nameemail:emaillanguage:localeCopy
1
The "dummyAuthorizationGenerator" part or something similar must be kept in the configuration, because without "authorizationGenerators", the SSO module does not start.
2
Don’t forget to adjust the values from Azure AD including the <tenant> in oidc.discoveryUri.
Testing
Test with a user who has no group assignments.
You should get a 403 error because there are no groups, and therefore no permissions.
Test with a user who has groups that match the Magnolia groups, such as travel-demo-editors login with correct permissions. The local Magnolia groups must match.