Microsoft Azure Active Directory custom group authentication
This article is based on Magnolia SSO module 3.1.3 and Magnolia CMS 6.2.33, therefore you must have a DX Core license. You cannot use the same configuration with SSO 2.x, but the logic of group resolution in combination with Azure AD is the same. In Azure, it’s possible to configure application roles, but this is not covered in this article. 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 article assumes you are an advanced user of Magnolia and familiar with Azure AD and SSO. |
This article 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.
Azure AD with a custom authorization generator
You need a custom Maven Module to use this solution.
Service Provider Interface (SPI)
-
In your custom module, navigate to and create a
services
directorycd src/main/resources/META-INF && mkdir services
-
Create a text file named:
info.magnolia.sso.config.spi.AuthorizationGeneratorProvider
-
Add the name of your custom provider to the file, such as:
info.magnolia.sso.auth.azure.CustomAzureAdAuthorizationGeneratorProvider
Maven and module dependencies
Add the Magnolia SSO dependency to your Maven pom.xml
and to the module descriptor file: /src/main/resources/META-INF/magnolia/<your-module-name>.xml
.
Custom classes
Create the Java class for your custom authorization generator and create the Java class for your custom provider:
package info.magnolia.sso.auth.azure;
public class CustomAzureAdAuthorizationGenerator implements AuthorizationGenerator {
private static final Logger log = LoggerFactory.getLogger(CustomAzureAdAuthorizationGenerator.class);
@Override
public Optional<UserProfile> generate(WebContext context, SessionStore sessionStore, UserProfile profile) {
log.info("Hello this is my new custom authorization generator ...");
return Optional.of(profile);
}
}
package info.magnolia.sso.auth.azure;
import org.pac4j.core.authorization.generator.AuthorizationGenerator;
public class CustomAzureAdAuthorizationGeneratorProvider implements info.magnolia.sso.config.spi.AuthorizationGeneratorProvider {
@Override
public AuthorizationGenerator create() {
return new CustomAzureAdAuthorizationGenerator();
}
}
Magnolia SSO configuration
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 Adapt this configuration to your setup.
This example runs under the |
config.yaml
example for Azure AD# tested with SSO 3.1.3
path: /.magnolia/admincentral
callbackUrl: http://localhost:8080/magnoliaAuthor/.auth
postLogoutRedirectUri: http://localhost:8080/magnoliaAuthor/.magnolia/admincentral
authorizationGenerators:
- name: fixedRoleAuthorization
fixed:
targetRoles:
- superuser
- name: dummyAuthorizationGenerator
clients:
oidc.id: 7b5bbb6c-f71f-52e4-b646-d3b332a1c10e
oidc.secret: TXm9Q~s_tS2iILqPzYm~jqwaGUIQoxyphGKrecip
oidc.clientAuthenticationMethod: client_secret_post
oidc.scope: openid profile email
# use your tenant from Azure AD - you need the value later for the custom provider class
oidc.discoveryUri: https://login.microsoftonline.com/f7c33569-d9fg-87e6-a2af-3e4feq02310c/v2.0/.well-known/openid-configuration
oidc.preferredJwsAlgorithm: RS256
oidc.authorizationGenerators: fixedRoleAuthorization,dummyAuthorizationGenerator,CustomAzureAdAuthorizationGeneratorProvider
userFieldMappings:
name: preferred_username
removeEmailDomainFromUserName: true
removeSpecialCharactersFromUserName: false
fullName: name
email: email
language: locale
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 isfound
: 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 namemgnlGroups
. -
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.
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;
import static java.util.function.Predicate.not;
import static java.util.stream.Collectors.toList;
public class CustomAzureAdAuthorizationGenerator implements AuthorizationGenerator {
private static final 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 names
private static final String GROUPS_PROPERTY = "groups";
// used to resolve groups in SSOAuthenticationModule
private static final String MGNL_GROUPS_ATTR_NAME = "mgnlGroups";
// property for resolving group memberships when there is no group or role attribute in the token payload
private static final 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>
private static final String AZURE_GROUP_ID_PREFIX = "https://graph.microsoft.com/v1.0/";
private static final String AZURE_GROUP_ID_SUFFIX = "/groups/";
// common properties
private final static String MS_GRAPH_VALUE_PROPERTY = "value";
private final static String MS_GRAPH_GROUP_NAME_KEY = "displayName";
// TODO Replace with your own tenant ID!
private final static String tenant = "f7c33569-d9fg-87e6-a2af-3e4feq02310c"; (1)
@Override
public 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()));
} else if (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.
*/
protected static 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.
*/
protected static void getAzureAdGroupNamesFromId(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.
*/
private static 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);
final int 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)
protected static 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;
}
}
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
:
# tested with SSO 3.1.3
path: /.magnolia/admincentral
callbackUrl: http://localhost:8080/magnoliaAuthor/.auth
postLogoutRedirectUri: http://localhost:8080/magnoliaAuthor/.magnolia/admincentral
authorizationGenerators: (1)
- name: dummyAuthorizationGenerator
clients:
oidc.id: 7b5bbb6c-f71f-52e4-b646-d3b332a1c10e
oidc.secret: TXm9Q~s_tS2iILqPzYm~jqwaGUIQoxyphGKrecip
oidc.clientAuthenticationMethod: client_secret_post
oidc.scope: openid profile email
# use your tenant from Azure AD
oidc.discoveryUri: https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration (2)
oidc.preferredJwsAlgorithm: RS256
oidc.authorizationGenerators: dummyAuthorizationGenerator,CustomAzureAdAuthorizationGeneratorProvider (1)
userFieldMappings:
name: preferred_username
removeEmailDomainFromUserName: true
removeSpecialCharactersFromUserName: false
fullName: name
email: email
language: locale
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.