BASIC USER OPERATIONS WITH MICROSOFT GRAPH API
TLDR;
The post shows how to configure AADB2C for an application with read/write permissions to perform operations on users. The post also shows how to perform create, read, update, delete (CRUD) operations on a user in C#, using the Microsoft.Graph package's2 GraphServiceClient.
If you just want to see the source code for the application, check out the source code repository.
The code snippet are from the UserRepository.cs file.
If you're interested in how I configured GraphServiceClient for the application, see the ClientCredentialsProvider.cs file and the Startup.cs file.
Introduction
Microsoft Graph exposes REST APIs and client libraries to access data on a number of different Microsoft cloud services (ref. 1). Azure AD B2C (AADB2C) is one those Microsoft cloud services. Hence, Microsoft Graph API exposes methods to perform a number of operations on an AADB2C user.
Prerequisites
If you haven't done so already, go read my other posts:
- Azure AD B2C Custom Policies (Series): Preface
- Azure AD B2C Custom Policies (Series): Extension file
Configure Azure AD B2C
Before we can start to use Microsoft Graph API, we need to configure a few things in AADB2C.
We need to:
- Add an application registration for the application that is going to use Microsoft Graph API.
- Add a client secret in the registered application:
I set the client secret to never expire. In a production environment, you should consider setting an expiration date.
- Add application permissions:
I have added a permission with the name 'User.ReadWrite.All', to be able to create, read, update and delete users. After adding the permission, click 'Grant admin consent for [...]'.
Having configured AADB2C, I will move on to set up an application corresponding to the app registration I added in the first step. I called the app registration SSOAdapter and I will call my application the same.
Application
I set the application up as a .NET Core API application.
I'm going to be using GraphServiceClient from the Microsoft Graph package (ref. 2). The client needs to be configured with an authentication provider. I registered the client during application start-up in order for dependency injection to work. I'll skip the implementation details here.
Next up, I'll dive right into the implementation of a user repository that utilises the GraphServiceClient to interact with Microsoft Graph API.
GET
I added a method called 'Get' in order to read a user from AADB2C:
public async Task<User> Get(string email)
{
var userPrincipalName = HttpUtility.UrlEncode(UserPrincipalName(email));
try
{
var result = await _graphServiceClient
.Users[userPrincipalName]
.Request()
.Select(UserProperties())
.GetAsync();
return result;
}
catch (ServiceException serviceException)
{
var statusCode = serviceException.StatusCode;
if (statusCode != HttpStatusCode.NotFound)
logger.LogInformation(
serviceException,
$"Status code: {statusCode}."
+ $"Unable to get user by user principal name: {userPrincipalName}");
}
catch (Exception ex)
{
_logger.LogInformation(ex, $"Unable to get user by user principal name: {userPrincipalName}");
}
return null;
}
It relies on the 'userPrincipalName' being known beforehand, so the user can be requested by it. I added claims transformations to my 'Extensions file' earlier to prefix 'userPrincipalName' with a formatted version of the 'email' claim. In the 'Get' method, I format the email parameter into a ' userPrincipalName' consistent with how it's done in the claims transformations and pass it to the request for a user.
If you don't agree with the above method or you're working with existing users that have a different 'userPrincipalName' format, you still have options.
Here's an alternative 'Get' method that doesn't rely on knowing the 'userPrincipalName' but uses the 'email' parameter as-is:
public async Task<User> Get(string email)
{
if (string.IsNullOrWhiteSpace(email)) return null;
email = HttpUtility.UrlEncode(email);
try
{
var results = await _graphServiceClient.Users
.Request()
.Select(UserProperties())
.Filter(MailFilterQuery(email))
.GetAsync();
return results?.FirstOrDefault();
}
catch (ServiceException serviceException)
{
var statusCode = serviceException.StatusCode;
if (statusCode != HttpStatusCode.NotFound)
_logger.LogInformation(serviceException, $"Status code: {statusCode}. Unable to get user by e-mail: {email}");
}
catch (Exception ex)
{
_logger.LogInformation(ex, $"Unable to get user by e-mail: {email}");
}
return null;
}
The 'Get' method requests all user's with a number of selected properties and filters them based on their 'mail' property. The method then returns the first user (based on the assumption that a user's mail is unique, only one user should remain after filtering).
The 'Get' method uses a few private methods. I have added some methods to specify regular user properties and user extension properties:
private string UserProperties()
{
var properties = new List<string>
{
Constants.User.Property.Id,
Constants.User.Property.GivenName,
Constants.User.Property.SurName,
Constants.User.Property.Mail,
Constants.User.Property.UserPrincipalName,
};
properties.AddRange(ExtensionProperties());
return string.Join(",", properties);
}
private IEnumerable<string> ExtensionProperties()
{
return new List<string>
{
ExtensionsKey(Constants.User.AdditionalData.CompanyCVR)
};
}
private string ExtensionsKey(string customUserAttributeName)
{
var extensionsAppId = _configuration?.ExtensionsAppId?.Replace(Constants.Separators.GuidGroup, string.Empty);
return $"extension_{extensionsAppId}_{customUserAttributeName}";
}
I have added another private method to return the query on the 'mail' property:
private static string MailFilterQuery(string username)
{
return $"mail eq '{username}'";
}
CREATE
Next, I have added a method called 'Create' to create a user in AADB2C:
public async Task<User> Create(CreateUserRequest request)
{
if (request == null) return null;
try
{
var user = User(request);
var addedUser = await
_graphServiceClient.Users
.Request()
.Select(UserProperties())
.AddAsync(user);
return addedUser;
}
catch (Exception ex)
{
_logger.LogInformation(ex, $"Unable to create user with email: {request?.EmailAddress}: {ex.Message}");
return null;
}
}
The private method where the user object is built looks as follows:
private User User(CreateUserRequest request)
{
var email = request.EmailAddress;
if (string.IsNullOrWhiteSpace(email)) return null;
var passwordProfile = PasswordProfile();
var userPrincipalName = UserPrincipalName(email);
var mailNickname = MailNickname(request.GivenName, request.Surname);
var displayName = DisplayName(request.GivenName, request.Surname);
var identities = Identities(email);
var additionalData = AdditionalData(request);
var user = new User
{
AccountEnabled = true,
AdditionalData = additionalData,
DisplayName = displayName,
GivenName = request.GivenName,
Identities = identities,
Mail = email,
MailNickname = mailNickname,
PasswordProfile = passwordProfile,
Surname = request.Surname,
UserPrincipalName = userPrincipalName,
};
return user;
}
There are a few properties that are required in order to create a user. At the time of writing this post, those are:
- Password profile
- Mail nickname
- Identities
- User principal name
I added private methods to create each of those properties.
Password profile:
private PasswordProfile PasswordProfile()
{
return new PasswordProfile
{
// Forcing password change results in 'expired' upon trying to login.
// There's an open feature request for this here:
// https://feedback.azure.com/forums/169401-azure-active-directory/suggestions/16861051-aadb2c-force-password-reset.
ForceChangePasswordNextSignIn = false,
Password = _temporaryPasswordService.TemporaryPassword(Constants.TemporaryPassword.SpecialCharacters, _configuration.TemporaryPasswordMinimumLength)
};
}
I'll leave it up to you to ensure the set password is strong.
Mail nickname:
private readonly Regex _nonLetterOrNumberPattern = new Regex("[^a-zA-Z0-9æÆøØåÅ]");
private string MailNickname(string givenName, string surname)
{
var nickname = givenName + surname;
nickname = _nonLetterOrNumberPattern.Replace(nickname, string.Empty);
return nickname;
}
From my experience a mail nickname can not contain whitespace, so I made it the contatenation of given name and surname. I allowed numbers as well.
Identities:
private IEnumerable<ObjectIdentity> Identities(string email)
{
var identities = new List<ObjectIdentity>
{
new ObjectIdentity
{
SignInType = Constants.SignInType.EmailAddress,
Issuer = _configuration.Domain,
IssuerAssignedId = email
}
};
return identities;
}
The 'Identities' property should reflect specific requirements for your application to support different types of sign-in types. In my case I only had to support sign-in using email address.
User principal name:
private readonly string _specialCharacters = "!#$%&'()*+,-./:;<=>?@[]^_`{|}~";
private readonly char _userPrincipalNameInvalidCharacterReplacement = '-';
private string UserPrincipalName(string email)
{
if (email == null)
return null;
var prefix = email;
foreach (var specialCharacter in _specialCharacters)
prefix = prefix.Replace(specialCharacter, _userPrincipalNameInvalidCharacterReplacement);
return $"{prefix}@{_configuration.Domain}";
}
Out-of-the-box, the user principal name is prefixed with a GUID when created via a user flow. I could have tried to replicate that, but I had an idea that I wanted to be able to get the user by the principal name. Creating ones that were based on a provided email was one way of achieving that. I did so under the assumption that an email is unique.
Optionally, you may need the created user to contain additional data. The private methods for that purpose:
private Dictionary<string, object> AdditionalData(CreateUserRequest request)
{
var additionalData = AdditionalData(request.CompanyCVR);
return additionalData;
}
private Dictionary<string, object> AdditionalData(string companyCvr)
{
var additionalData = new Dictionary<string, object>();
if (!string.IsNullOrWhiteSpace(companyCvr))
additionalData[ExtensionsKey(Constants.User.AdditionalData.CompanyCVR)] = companyCvr;
return additionalData;
}
The method for creating a display name is simple, based on an assumption that the order between given name and surname is invariant:
private string DisplayName(string givenName, string surname)
{
return $"{givenName} {surname}";
}
UPDATE
I have gone ahead and added a method to update an existing user in AADB2C.
Here's an 'Update' method that does so using the user's 'userPrincipalName' as a parameter to the request:
public async Task<User> Update(UpdateUserRequest request)
{
if (request == null) return null;
var email = request.EmailAddress;
if (string.IsNullOrWhiteSpace(email)) return null;
var userPrincipalName = UserPrincipalName(email);
if (string.IsNullOrWhiteSpace(userPrincipalName)) return null;
var user = User(request);
if (user == null) return null;
if (await TryAssignByUserPrincipalName(userPrincipalName, user))
return user;
return null;
}
Here's an 'Update' method that does so using the user's email as a parameter to the request:
public async Task<User> Update(UpdateUserRequest request)
{
if (request == null) return null;
var email = request.EmailAddress;
if (string.IsNullOrWhiteSpace(email)) return null;
var user = User(request);
if (user == null) return null;
if (await TryUpdateByEmail(email, user))
return user;
return null;
}
The private method where the updated user object is built looks as follows:
private User User(UpdateUserRequest request)
{
// Only fields with values will be updated, so we can just assign without scrutiny.
// Assigning to existing user is not permitted
if (request == null) return null;
var mailNickname = MailNickname(request.GivenName, request.Surname);
var displayName = DisplayName(request.GivenName, request.Surname);
var updatedUser = new User
{
GivenName = request.GivenName,
Surname = request.Surname,
DisplayName = displayName,
MailNickname = mailNickname,
AdditionalData = AdditionalData(request.CompanyCVR)
};
return updatedUser;
}
Once again a lot of reuse of private methods I added prior, for creating a user.
Depending on which version of the 'Update' method you end up using, there's a corresponding private method where the updating is actually performed:
private async Task<bool> TryUpdateByEmail(string email, User user)
{
try
{
email = HttpUtility.UrlEncode(email);
var results = await _graphServiceClient.Users
.Request()
.Filter(MailFilterQuery(email))
.GetAsync();
var result = results?.FirstOrDefault();
if (result == null) return false;
var userPrincipalName = result.UserPrincipalName;
return await TryAssignByUserPrincipalName(userPrincipalName, user);
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Unable to update user by email");
}
return false;
}
private async Task<bool> TryAssignByUserPrincipalName(string userPrincipalName, User user)
{
if (string.IsNullOrWhiteSpace(userPrincipalName)) return false;
if (user == null) return false;
try
{
await _graphServiceClient.Users[userPrincipalName]
.Request()
.UpdateAsync(user);
return true;
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Unable to update user by user principal name");
}
return false;
}
DELETE
To wrap up the work on basic operations on an AADB2C user, I have added a method to delete an existing user in AADB2C:
public async Task Delete(string email)
{
if (string.IsNullOrWhiteSpace(email)) return;
try
{
var userPrincipalName = UserPrincipalName(email);
if (string.IsNullOrWhiteSpace(userPrincipalName)) return;
await _graphServiceClient.Users[userPrincipalName]
.Request()
.DeleteAsync();
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Unable to delete user by email");
}
return;
}
Here's a version of the 'Delete' method where the email is used as-is:
public async Task Delete(string email)
{
try
{
var user = await Get(email);
if (user == null) return;
var userPrincipalName = user.UserPrincipalName;
if (string.IsNullOrWhiteSpace(userPrincipalName)) return;
await _graphServiceClient.Users[userPrincipalName]
.Request()
.DeleteAsync();
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Unable to delete user by email");
}
return;
}
Once again I'm reusing the 'Get' method from earlier and then deleting based on the retrieved user's 'userPrincipalName'.
That concludes what I wanted to show you, about performing basic operations on an AADB2C user, using Microsoft Graph API.