Creating Microsoft Teams meetings in ASP.NET Core using Microsoft Graph application permissions part 2

This article shows how to create Microsoft Teams meetings in ASP.NET Core using Microsoft Graph with application permissions. This is useful if you have a designated account to manage or create meetings, send emails or would like to provide a service for users without an office account to create meetings. This is a follow up post to part one in this series which creates Teams meetings using delegated permissions.

Code: https://github.com/damienbod/TeamsAdminUI

History

2023-01-15 Updated to .NET 7, improved Graph application client

Blogs in this series

Setup Azure App registration

A simple ASP.NET Core application with no authentication was created and implements a form which creates online meetings on behalf of a designated account using Microsoft Graph with application permissions. The Microsoft Graph client uses an Azure App registration for authorization and the client credentials flow is used to authorize the client and get an access token. No user is involved in this flow and the application requires administration permissions in the Azure App registration for Microsoft Graph.

An Azure App registration is setup to authenticate against Azure AD. The ASP.NET Core application will use application permissions for the Microsoft Graph. The listed permissions underneath are required to create the Teams meetings OBO and to send emails to the attendees using the configuration email which has access to office.

Microsoft Graph application permissions:

  • User.Read.All
  • Mail.Send
  • Mail.ReadWrite
  • OnlineMeetings.ReadWrite.All

This is the list of permissions I have activate for this demo.

Configuration

The Azure AD configuration is used to get a new access token for the Microsoft Graph client and to define the email of the account which is used to create Microsoft Teams meetings and also used to send emails to the attendees. This account needs an office account.

"AzureAd": {
	"TenantId": "5698af84-5720-4ff0-bdc3-9d9195314244",
	"ClientId": "b9be5f88-f629-46b0-ac4c-c5a4354ac192",
	// "ClientSecret": "add secret to the user secrets"
	"MeetingOrganizer": "--your-email-for-sending--"
},

Setup Client credentials flow to for Microsoft Graph

A number of different ways can be used to authorize a Microsoft Graph client and is a bit confusing sometimes. Using the DefaultCredential is not really a good idea for Graph because you need to decide if you use a delegated authorization or a application authorization and the DefaultCredential will take the first one which works and this depends on the environment. For application authorization, I use the ClientSecretCredential Identity to get the service access token. This requires the .default scope and a client secret or a client credential. Using a client secret is fine if you control both client and server and the secret is stored in an Azure Key Vault. A client certificate could also be used.

private GraphServiceClient GetGraphClient()
{
	string[] scopes = new[] { "https://graph.microsoft.com/.default" };
	var tenantId = _configuration["AzureAd:TenantId"];

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

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

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

	return new GraphServiceClient(clientSecretCredential, scopes);
}

The IConfidentialClientApplication interface could also be used to get access tokens which is used to authorize the Graph client. A simple in memory cache is used to store the access token. This token is reused until it expires or the application is restart. If using multiple instances, maybe a distributed cache would be better. The client uses the https://graph.microsoft.com/.default&#8221; scope to get an access token for the Microsoft Graph client. A GraphServiceClient instance is returned with a value access token.

public class ApiTokenInMemoryClient
{
	private readonly IHttpClientFactory _clientFactory;
	private readonly ILogger<ApiTokenInMemoryClient> _logger;

	private readonly IConfiguration _configuration;
	private readonly IConfidentialClientApplication _app;
	private readonly ConcurrentDictionary<string, AccessTokenItem> _accessTokens = new();

	private class AccessTokenItem
	{
		public string AccessToken { get; set; } = string.Empty;
		public DateTime ExpiresIn { get; set; }
	}

	public ApiTokenInMemoryClient(IHttpClientFactory clientFactory,
		IConfiguration configuration, ILoggerFactory loggerFactory)
	{
		_clientFactory = clientFactory;
		_configuration = configuration;
		_logger = loggerFactory.CreateLogger<ApiTokenInMemoryClient>();
		_app = InitConfidentialClientApplication();
	}

	public async Task<GraphServiceClient> GetGraphClient()
	{
		var result = await GetApiToken("default");

		var httpClient = _clientFactory.CreateClient();
		httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result);
		httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

		var graphClient = new GraphServiceClient(httpClient)
		{
			AuthenticationProvider = new DelegateAuthenticationProvider(async (requestMessage) =>
			{
				requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", result);
				await Task.FromResult<object>(null);
			})
		};

		return graphClient;
	}

	private async Task<string> GetApiToken(string api_name)
	{
		if (_accessTokens.ContainsKey(api_name))
		{
			var accessToken = _accessTokens.GetValueOrDefault(api_name);
			if (accessToken.ExpiresIn > DateTime.UtcNow)
			{
				return accessToken.AccessToken;
			}
			else
			{
				// remove
				_accessTokens.TryRemove(api_name, out _);
			}
		}

		_logger.LogDebug($"GetApiToken new from STS for {api_name}");

		// add
		var newAccessToken = await AcquireTokenSilent();
		_accessTokens.TryAdd(api_name, newAccessToken);

		return newAccessToken.AccessToken;
	}

	private async Task<AccessTokenItem> AcquireTokenSilent()
	{
		//var scopes = "User.read Mail.Send Mail.ReadWrite OnlineMeetings.ReadWrite.All";
		var authResult = await _app
			.AcquireTokenForClient(scopes: new[] { "https://graph.microsoft.com/.default" })
			.WithAuthority(AzureCloudInstance.AzurePublic, _configuration["AzureAd:TenantId"])
			.ExecuteAsync();

		return new AccessTokenItem
		{
			ExpiresIn = authResult.ExpiresOn.UtcDateTime,
			AccessToken = authResult.AccessToken
		};
	}

	private IConfidentialClientApplication InitConfidentialClientApplication()
	{
		return ConfidentialClientApplicationBuilder
			.Create(_configuration["AzureAd:ClientId"])
			.WithClientSecret(_configuration["AzureAd:ClientSecret"])
			.Build();
	}
}

OnlineMeetings Graph Service

The AadGraphApiApplicationClient service is used to send the Microsoft Graph requests. This uses the graphServiceClient client with the correct access token. The GetUserIdAsync method is used to get the Graph Id using the UPN. This is used in the Users API to run the requests with the application scopes. The Me property is not used as this is for delegated scopes. We have no user in this application. We run the requests as an application on behalf of the designated user.

using Microsoft.Graph;

namespace TeamsAdminUIObo.GraphServices;

public class AadGraphApiApplicationClient
{
    private readonly IConfiguration _configuration;
    private readonly GraphApplicationClientService _graphApplicationClientService;

    public AadGraphApiApplicationClient(IConfiguration configuration, 
        GraphApplicationClientService graphApplicationClientService)
    {
        _configuration = configuration;
        _graphApplicationClientService = graphApplicationClientService;
    }

    private async Task<string> GetUserIdAsync()
    {
        var meetingOrganizer = _configuration["AzureAd:MeetingOrganizer"];
        var filter = $"startswith(userPrincipalName,'{meetingOrganizer}')";
        var graphServiceClient = _graphApplicationClientService.GetGraphClientWithManagedIdentityOrDevClient();

        var users = await graphServiceClient.Users
            .Request()
            .Filter(filter)
            .GetAsync();

        return users.CurrentPage[0].Id;
    }

    public async Task SendEmailAsync(Message message)
    {
        var graphServiceClient = _graphApplicationClientService.GetGraphClientWithManagedIdentityOrDevClient();

        var saveToSentItems = true;

        var userId = await GetUserIdAsync();

        await graphServiceClient.Users[userId]
            .SendMail(message, saveToSentItems)
            .Request()
            .PostAsync();
    }

    public async Task<OnlineMeeting> CreateOnlineMeeting(OnlineMeeting onlineMeeting)
    {
        var graphServiceClient = _graphApplicationClientService.GetGraphClientWithManagedIdentityOrDevClient();

        var userId = await GetUserIdAsync();

        return await graphServiceClient.Users[userId]
            .OnlineMeetings
            .Request()
            .AddAsync(onlineMeeting);
    }

    public async Task<OnlineMeeting> UpdateOnlineMeeting(OnlineMeeting onlineMeeting)
    {
        var graphServiceClient = _graphApplicationClientService.GetGraphClientWithManagedIdentityOrDevClient();

        var userId = await GetUserIdAsync();

        return await graphServiceClient.Users[userId]
            .OnlineMeetings[onlineMeeting.Id]
            .Request()
            .UpdateAsync(onlineMeeting);
    }

    public async Task<OnlineMeeting> GetOnlineMeeting(string onlineMeetingId)
    {
        var graphServiceClient = _graphApplicationClientService.GetGraphClientWithManagedIdentityOrDevClient();

        var userId = await GetUserIdAsync();

        return await graphServiceClient.Users[userId]
            .OnlineMeetings[onlineMeetingId]
            .Request()
            .GetAsync();
    }
}

The GraphApplicationClientService service is used to setup the Graph application client. A managed identity is used for production and an Azure app registration with a client secret is used for development.

using Azure.Identity;
using Microsoft.Graph;

namespace TeamsAdminUIObo.GraphServices;

public class GraphApplicationClientService
{
    private readonly IConfiguration _configuration;
    private readonly IHostEnvironment _environment;
    private GraphServiceClient? _graphServiceClient;

    public GraphApplicationClientService(IConfiguration configuration, IHostEnvironment environment)
    {
        _configuration = configuration;
        _environment = environment;
    }

    /// <summary>
    /// gets a singleton instance of the GraphServiceClient
    /// </summary>
    /// <returns></returns>
    public GraphServiceClient GetGraphClientWithManagedIdentityOrDevClient()
    {
        if (_graphServiceClient != null)
            return _graphServiceClient;

        string[] scopes = new[] { "https://graph.microsoft.com/.default" };

        var chainedTokenCredential = GetChainedTokenCredentials();
        _graphServiceClient = new GraphServiceClient(chainedTokenCredential, scopes);

        return _graphServiceClient;
    }

    private ChainedTokenCredential GetChainedTokenCredentials()
    {
        if (!_environment.IsDevelopment())
        {
            return new ChainedTokenCredential(new ManagedIdentityCredential());
        }
        else // dev env
        {
            var tenantId = _configuration["AzureAd:TenantId"];
            var clientId = _configuration.GetValue<string>("AzureAd:ClientId");
            var clientSecret = _configuration.GetValue<string>("AzureAd:ClientSecret");

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

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

            var chainedTokenCredential = new ChainedTokenCredential(devClientSecretCredential);

            return chainedTokenCredential;
        }
    }
}

The startup class adds the services as required. No authentication is added for the ASP.NET Core application.

public void ConfigureServices(IServiceCollection services)
{
	services.AddScoped<AadGraphApiApplicationClient>();
	services.AddSingleton<GraphApplicationClientService>();
	services.AddScoped<EmailService>();
	services.AddScoped<TeamsService>();
	services.AddHttpClient();
	services.AddOptions();

	services.AddRazorPages();
}

Azure Policy configuration

We need to allow applications to access online meetings on behalf of a user with this setup. This is implemented using the following documentation:

https://docs.microsoft.com/en-us/graph/cloud-communication-online-meeting-application-access-policy

Testing

When the application is started, you can create a new Teams meeting with the required details. The configuration email must have an account with access to Office and be on the same tenant as the Azure App registration setup for the Microsoft Graph application permissions. The Email must have a policy setup to allow the Microsoft Graph calls. The Teams meeting is organized using the identity that signed in because we used the applications permissions.

This works really well and can be used for Azure B2C solutions as well. If possible, you should only use delegated scopes in the application, if possible. By using application permissions, the ASP.NET Core is implicitly an administrator of these permissions as well. It would be better if user accounts with delegated access was used which are managed by your IT etc.

Links:

https://docs.microsoft.com/en-us/graph/api/application-post-onlinemeetings

https://github.com/AzureAD/microsoft-identity-web

Send Emails using Microsoft Graph API and a desktop client

https://www.office.com/?auth=2

https://aad.portal.azure.com/

https://admin.microsoft.com/Adminportal/Home

https://blazorhelpwebsite.com/ViewBlogPost/43

13 comments

  1. […] Creating Microsoft Teams meetings in ASP.NET Core using Microsoft Graph application permissions part… (Damien Bowden) […]

  2. […] Creating Microsoft Teams meetings in ASP.NET Core using Microsoft Graph application permissions part… (Damien Bowden) […]

  3. […] Randolph) Technology & Friends – Richard Campbell on the Future of Space Travel (David Giard) Creating Microsoft Teams meetings in ASP.NET Core using Microsoft Graph application permissions part… (Damien Bowden) Using .NET MAUI Community Toolkit (Brandon […]

  4. Thanks Damien. I am waiting for this one, you helped me a lot.

  5. Hi Damien, I am following this tutorial of yours, do I need to the create policy through Manage Skype for Business Online with PowerShell or theres another way around for that?

    1. You need to create the policy with powershell which has access to the tenant in the portal.

      Greetings Damien

      1. I am having hard time to implement this one, do you have any tutorial for this policy to work?

      2. I followed these docs:

        https://docs.microsoft.com/en-us/graph/cloud-communication-online-meeting-application-access-policy

        I thought it worked pretty good. The hardest bit was installing the correct Powershell modules and logging into the correct tenant.

        Hope this helps

        Greetings Damien

      3. Is it possible you include that part in your tutorial? I am having really hard time to enable that policy. It seems like the docs doesn’t provide everything.

  6. […] Creating Microsoft Teams meetings in ASP.NET Core using Microsoft Graph application permissions part… – Damien Bowden […]

  7. […] The MsGraphEmailService class implements the Microsoft Graph email service. This client needs to authorize using an Azure tenant which has an office license for the sender account. The application permissions also need to be enabled for the Azure App registration used. This works fine as long as you do not send loads of emails, the amount of mails you can send is limited and you do not want to send many emails anyway. The Azure SDK ClientSecretCredential is used which setups the client credentials flow and uses an application scope from the Azure App registration. For more details, see this post. […]

  8. […] The MsGraphEmailService class implements the Microsoft Graph email service. This client needs to authorize using an Azure tenant which has an office license for the sender account. The application permissions also need to be enabled for the Azure App registration used. This works fine as long as you do not send loads of emails, the amount of mails you can send is limited and you do not want to send many emails anyway. The Azure SDK ClientSecretCredential is used which setups the client credentials flow and uses an application scope from the Azure App registration. For more details, see this post. […]

  9. […] Creating Microsoft Teams meetings in ASP.NET Core using Microsoft Graph application permissions part… […]

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 )

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: