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

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" 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.

 public class AadGraphApiApplicationClient { 	private readonly IConfiguration _configuration;  	public AadGraphApiApplicationClient(IConfiguration configuration) 	{ 		_configuration = configuration; 	}  	private async Task<string> GetUserIdAsync() 	{ 		var meetingOrganizer = _configuration["AzureAd:MeetingOrganizer"]; 		var filter = $"startswith(userPrincipalName,'{meetingOrganizer}')"; 		var graphServiceClient = GetGraphClient();  		var users = await graphServiceClient.Users 			.Request() 			.Filter(filter) 			.GetAsync();  		return users.CurrentPage[0].Id; 	}  	public async Task SendEmailAsync(Message message) 	{ 		var graphServiceClient = GetGraphClient();  		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 = GetGraphClient();  		var userId = await GetUserIdAsync();  		return await graphServiceClient.Users[userId] 			.OnlineMeetings 			.Request() 			.AddAsync(onlineMeeting); 	}  	public async Task<OnlineMeeting> UpdateOnlineMeeting(OnlineMeeting onlineMeeting) 	{ 		var graphServiceClient = GetGraphClient();  		var userId = await GetUserIdAsync();  		return await graphServiceClient.Users[userId] 			.OnlineMeetings[onlineMeeting.Id] 			.Request() 			.UpdateAsync(onlineMeeting); 	}  	public async Task<OnlineMeeting> GetOnlineMeeting(string onlineMeetingId) 	{ 		var graphServiceClient = GetGraphClient();  		var userId = await GetUserIdAsync();  		return await graphServiceClient.Users[userId] 			.OnlineMeetings[onlineMeetingId] 			.Request() 			.GetAsync(); 	}  	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 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<ApiTokenInMemoryClient>(); 	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