Invite external users to Azure AD using Microsoft Graph and ASP.NET Core

This post shows how to invite new Azure AD external guest users and assign the users to Azure AD groups using an ASP.NET Core APP Connector to import or update existing users from an external IAM and synchronize the users in Azure AD. The authorization can be implemented using Azure AD groups and can be imported or used in the ASP.NET Core API.

Setup

The APP Connector or the IAM connector is implemented using ASP.NET Core and Microsoft Graph. Two Azure APP registrations are used, one the for the external application and a second for the Microsoft Graph access. Both applications use an application client and can be run as background services, console applications or whatever. Only the APP Connector has access to the Microsoft Graph API and the graph application permissions are allowed only for this client. This way, the Microsoft Graph client can be controlled as a lot of privileges are required to add, update and delete users or add and remove group assignments. We only allow the client explicit imports or updates for guest users. The APP Connector sends invites to the new external guest users and the users can then authentication using an email code. The correct groups are then assigned to the user depending on the API payload. With this, it is possible to keep external user accounting and manage the external identities in AAD without having to migrate the users. One unsolved problem with this solution is single sign on (SSO). It would be possible to achieve this, if all the external users came from the same domain and the external IAM system supported SAML. AAD does not support OpenID Connect for this.

Microsoft Graph client

A confidential client is used to get an application access token for the Microsoft Graph API calls. The .default scope is used to request the access token using the OAuth client credentials flow. The Azure SDK ClientSecretCredential is used to authorize the client.

public MsGraphService(IConfiguration configuration,
	IOptions<GroupsConfiguration> groups,
	ILogger<MsGraphService> logger)
{
	_groups = groups.Value;
	_logger = logger;
	string[]? scopes = configuration.GetValue<string>
		("AadGraph:Scopes")?.Split(' ');
		
	var tenantId = configuration.GetValue<string>
		("AadGraph:TenantId");

	// Values from app registration
	var clientId = configuration.GetValue<string>
		("AadGraph:ClientId");
		
	var clientSecret = configuration.GetValue<string>
		("AadGraph:ClientSecret");

	_federatedDomainDomain = configuration.GetValue<string>
		("FederatedDomain");

	var options = new TokenCredentialOptions
	{
		AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
	};

// https://docs.microsoft.com/dotnet/api/azure.identity.clientsecretcredential
	var clientSecretCredential = new ClientSecretCredential(
		tenantId, clientId, clientSecret, options);

	_graphServiceClient = new GraphServiceClient(
		clientSecretCredential, scopes);
}

The following permissions were added to the Azure App registration for the Graph requests. See the Microsoft Graph documentation to see what permissions are required for what API request. All permissions are application permissions as an application access token was requested. No user is involved.

  • Directory.Read.All
  • Directory.ReadWrite.All
  • Group.Read.All
  • Group.ReadWrite.All
  • // if role assignments are used in the Azure AD group
    RoleManagement.ReadWrite.Directory
  • User.Read.All
  • User.ReadWrite.All

ASP.NET Core application client

The external identity system also uses the client credentials to access the APP Connector API, this time using an access token from the second Azure App registration. This is separated from the Azure App registration used for the Microsoft Graph requests. The scopes is defined to use the “.default” value which requires no consent.

// 1. Client client credentials client
var app = ConfidentialClientApplicationBuilder
    .Create(configuration["AzureAd:ClientId"])
    .WithClientSecret(configuration["AzureAd:ClientSecret"])
    .WithAuthority(configuration["AzureAd:Authority"])
    .Build();

var scopes = new[] { configuration["AzureAd:Scope"] };

// 2. Get access token
var authResult = await app.AcquireTokenForClient(scopes)   
    .ExecuteAsync();

Implement the user invite

I decided to invite the users from the external identity providers in the Azure AD as guest users. At present, the default authentication sends a code to the user email which can be used to sign-in. You could create new AAD users, or even a federated AAD user. Single sign on will only work for google, facebook or a SAML domain federation where users come from the same domain. I wish for an OpenID Connect external authentication button in my sign-in UI where I can decide which users and from what domain authenticate in my AAD. This is where AAD is really lagging behind other identity providers.

/// <summary>
/// Graph invitations only works for Azure AD, not Azure B2C
/// </summary>
public async Task<Invitation?> InviteUser(UserModel userModel, string redirectUrl)
{
	var invitation = new Invitation
	{
		InvitedUserEmailAddress = userModel.Email,
		InvitedUser = new User
		{
			GivenName = userModel.FirstName,
			Surname = userModel.LastName,
			DisplayName = $"{userModel.FirstName} {userModel.LastName}",
			Mail = userModel.Email,
			UserType = "Guest", // Member
			OtherMails = new List<string> { userModel.Email },
			Identities = new List<ObjectIdentity>
			{
				new ObjectIdentity
				{
					SignInType = "federated",
					Issuer = _federatedDomainDomain,
					IssuerAssignedId = userModel.Email
				},
			},
			PasswordPolicies = "DisablePasswordExpiration"
		},
		SendInvitationMessage = true,
		InviteRedirectUrl = redirectUrl,
		InvitedUserType = "guest" // default is guest,member
	};

	var invite = await _graphServiceClient.Invitations
		.Request()
		.AddAsync(invitation);

	return invite;
}

Adding, Removing AAD users and groups

Once the users exist in the AAD tenant, you can assign the users to groups, remove assignments, remove users or update users. If a user is disabled in the external IAM system, you cannot disable the user in the AAD with an application permission, you can only delete the user. You can assign security groups or M365 groups to the AAD guest user. With this, the AAD IT admin can manage guest users and assign the group of guests to any AAD application.

public async Task AddRemoveGroupMembership(string userId,
	List<string>? accessRolesPermissions, List<string> 
	currentGroupIds, 
	string groudId,
	string groupType)
{
	if (accessRolesPermissions != null &&
		accessRolesPermissions.Any(g => g.Contains(groupType)))
	{
		await AddGroupMembership(userId, groudId, currentGroupIds);
	}
	else
	{
		await RemoveGroupMembership(userId, groudId, currentGroupIds);
	}
}

private async Task AddGroupMembership(string userId, string groupId, List<string> currentGroupIds)
{
	if (!currentGroupIds.Contains(groupId))
	{
		// add group
		await AddUserToGroup(userId, groupId);
		currentGroupIds.Add(groupId);
	}
}

private async Task RemoveGroupMembership(string userId, string groupId,List<string> currentGroupIds)
{
	if (currentGroupIds.Contains(groupId))
	{
		// remove group
		await RemoveUserFromGroup(userId, groupId);
		currentGroupIds.Remove(groupId);
	}
}

public async Task<User?> UserExistsAsync(string email)
{
	var users = await _graphServiceClient.Users
		.Request()
		.Filter($"mail eq '{email}'")
		.GetAsync();

	if (users.CurrentPage.Count == 0) 
		return null;

	return users.CurrentPage[0];
}

public async Task DeleteUserAsync(string userId)
{
	await _graphServiceClient.Users[userId]
	   .Request()
	   .DeleteAsync();
}

public async Task<User> UpdateUserAsync(User user)
{
	return await _graphServiceClient.Users[user.Id]
	   .Request()
	   .UpdateAsync(user);
}

public async Task<User> GetGraphUser(string userId)
{
	return await _graphServiceClient.Users[userId]
		.Request()
		.GetAsync();
}

public async Task<IDirectoryObjectGetMemberGroupsCollectionPage> 
	GetGraphUserMemberGroups(string userId)
{
	var securityEnabledOnly = false;

	return await _graphServiceClient.Users[userId]
		.GetMemberGroups(securityEnabledOnly)
		.Request()
		.PostAsync();
}

private async Task RemoveUserFromGroup(string userId, string groupId)
{
	try
	{
		await _graphServiceClient.Groups[groupId]
			.Members[userId]
			.Reference
			.Request()
			.DeleteAsync();
	}
	catch (Exception ex)
	{
		_logger.LogError(ex, "{Error} RemoveUserFromGroup", ex.Message);
	}
}

private async Task AddUserToGroup(string userId, string groupId)
{
	try
	{
		var directoryObject = new DirectoryObject
		{
			Id = userId
		};

		await _graphServiceClient.Groups[groupId]
			.Members
			.References
			.Request()
			.AddAsync(directoryObject);
	}
	catch (Exception ex)
	{
		_logger.LogError(ex, "{Error} AddUserToGroup", ex.Message);
	}
}

Create a new guest user with group assignments

I created a service which then creates a user and assigns the defined groups to the user using the Graph services defined above. You cannot select users or groups after creating them for n-seconds. It is important to use the request result from the create requests, otherwise you will have to implement the follow up tasks in a worker process or poll the graph API until the get returns the updated user or group.

public async Task<(UserModel? UserModel, string Error)> 
	CreateUserAsync(UserModel userModel)
{
	var emailValid = _msGraphService.IsEmailValid(userModel.Email);
	if (!emailValid)
	{
		return (null, "Email is not valid");
	}

	var user = await _msGraphService.UserExistsAsync(userModel.Email);
	if (user != null)
	{
		return (null, "User with this email already exists in AAD tenant");
	}

	var result = await _msGraphService.InviteUser(userModel,
						_configuration["InviteUserRedirctUrl"]);

	if (result != null)
	{
		await AssignmentGroupsAsync(
			result.InvitedUser.Id, 
			userModel.AccessRolesPermissions, 
			new List<string>());
	}

	return (userModel, string.Empty);
}

The UpdateAssignmentGroupsAsync and the AssignmentGroupsAsync maps the API definition to the configured Azure AD group and removes or adds the group as defined.

private async Task UpdateAssignmentGroupsAsync(string userId, List<string>? accessRolesPermissions)
{
	var currentGroupIds = await _msGraphService.GetGraphUserMemberGroups(userId);
	var currentGroupIdsList = currentGroupIds.ToList();
	await AssignmentGroupsAsync(userId, accessRolesPermissions, currentGroupIdsList);
}

private async Task AssignmentGroupsAsync(string userId,
	List<string>? accessRolesPermissions, List<string> currentGroupIds)
{
	await _msGraphService.AddRemoveGroupMembership(userId, 
		accessRolesPermissions, currentGroupIds, _groups.UserWorkspace, Consts.USER_WORKSPACE);
	await _msGraphService.AddRemoveGroupMembership(userId, 
		accessRolesPermissions, currentGroupIds, _groups.AdminWorkshop, Consts.ADMIN_WORKSPACE);
}

The service method can then be made public in a Web API which requires the AAD application access token. This access token will only work for the API. The graph API access token is never made public. The Graph API access token has a lot of permissions.

[HttpPost("Create")]
[ProducesResponseType(StatusCodes.Status201Created, 
	Type = typeof(UserModel))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[SwaggerOperation(OperationId = "Create-AAD-guest-Post", 
	Summary = "Creates an Azure AD guest user with assigned groups")]
public async Task<ActionResult<UserModel>> CreateUserAsync(
	[FromBody] UserModel userModel)
{
	var result = await _userGroupManagememtService
		.CreateUserAsync(userModel);

	if (result.UserModel == null)
		return BadRequest(result.Error);

	return Created(nameof(UserModel), result.UserModel);
}

Update or delete a guest User with group assignments

The UpdateDeleteUserAsync method deletes the AAD user, if the user is not active in the external identity system. If the user is still active, the AAD user gets updated. This will not take effect until the next authentication of the user or you could implement a policy to force a re-authentication. This depends upon the use case, it is not such a good experience, if the user id forced to update during a session, unless of course permissions were removed. The user gets assigned or removed from groups depending on the external authentication authorization definitions.

public async Task<(CreateUpdateResult? Result, string Error)> 
	§UpdateDeleteUserAsync(UserUpdateModel userModel)
{
	var emailValid = _msGraphService.IsEmailValid(userModel.Email);
	if (!emailValid)
	{
		return (null, "Email is not valid");
	}

	var user = await _msGraphService.UserExistsAsync(userModel.Email);
	if (user == null)
	{
		return (null, "User with this email does not exist");
	}

	if (userModel.IsActive)
	{
		user.GivenName = userModel.FirstName;
		user.Surname = userModel.LastName;
		user.DisplayName = $"{userModel.FirstName} {userModel.LastName}";

		await _msGraphService.UpdateUserAsync(user);

		await UpdateAssignmentGroupsAsync(user.Id, 
			userModel.AccessRolesPermissions);

		return (new CreateUpdateResult
		{
			Succeeded = true,
			Reason = $"{userModel.Email} {userModel.Username} updated"
		}, string.Empty);
	}
	else // not active, remove
	{
		await UpdateAssignmentGroupsAsync(user.Id, null);

		await _msGraphService.DeleteUserAsync(user.Id);

		return (new CreateUpdateResult
		{
			Succeeded = true,
			Reason = $"{userModel.Email} {userModel.Username} removed"
		}, string.Empty);
	}
}

The service implementation method can be made public in a secure Web API. This is not a update but a API service which updates or deletes a user and also assigns or removes groups for this user. I used a HTTP POST for this.

[HttpPost("UpdateUser")]
    [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(CreateUpdateResult))]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [SwaggerOperation(OperationId = "Update-AAD-guest-Post", Summary = "Updates or deletes an Azure AD guest user and assigned groups")]
    public async Task<ActionResult<CreateUpdateResult>> UpdateUserAsync([FromBody] UserUpdateModel userModel)
    {
        var update = await _userGroupManagememtService
            .UpdateUserAsync(userModel);

        if (update.Result == null)
            return BadRequest(update.Error);

        return Ok(update.Result);
    }

Testing the API using a Console application

Any trusted application can be used to implement the client. The client application must be a trusted application because a secret is required to access the web API. If you use a non trusted client, then a UI authentication user flow with delegated permissions must be used. The Graph API access is not made public to this client either way.

I implemented a test client in .NET Core. Any API call could look something like this:

static async Task<HttpResponseMessage> CreateUser(IConfigurationRoot configuration, 
    AuthenticationResult authResult)
{
    var client = new HttpClient
    {
        BaseAddress = new Uri(configuration["AzureAd:ApiBaseAddress"])
    };

    client.DefaultRequestHeaders.Authorization
        = new AuthenticationHeaderValue("Bearer", authResult.AccessToken);
    client.DefaultRequestHeaders.Accept
        .Add(new MediaTypeWithQualityHeaderValue("application/json"));

    var response = await client.PostAsJsonAsync("AadUsers/Create",
        new UserModel
        {
            Username = "paddy@test.com",
            Email = "paddy@test.com",
            FirstName = "Paddy",
            LastName = "Murphy",
            AccessRolesPermissions = new List<string> { "UserWorkspace" }
        });

    return response;
}

Notes

One problem with this system is that the user does not have a single sign on. Azure AD does not support this for multiple domains. It is a real pity that you cannot define an external identity provider in Azure AD which is then displayed in the Azure AD sign-in UI. To make Single Sign on with federation work in Azure AD, you must use Azure AD as the main accounting database. If all your external users have the same domain, then you could setup an SAML federation for this domain. If the users from the external domain have different domains, Azure AD does not support this. This is a big problem if you cannot migrate existing identity providers and the accounting to AAD and you require applications which require an AAD authentication.

Links

https://docs.microsoft.com/en-us/azure/active-directory/external-identities/what-is-b2b

https://docs.microsoft.com/en-us/azure/active-directory/external-identities/redemption-experience

2 comments

  1. […] Invite external users to Azure AD using Microsoft Graph and ASP.NET Core (Damien Bowden) […]

  2. […] Invite external users to Azure AD using Microsoft Graph and ASP.NET Core – Dameien Bowden […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: