- Day 28 - Webhooks
To complete this sample you need the following:
- Complete the Base Console Application Setup
- Visual Studio Code installed on your development machine. If you do not have Visual Studio Code, visit the previous link for download options. (Note: This tutorial was written with Visual Studio Code version 1.28.2. The steps in this guide may work with other versions, but that has not been tested.)
- .Net Core SDK. (Note This tutorial was written with .Net Core SDK 2.1.403. The steps in this guide may work with other versions, but that has not been tested.)
- C# extension for Visual Studio Code
- Either a personal Microsoft account with a mailbox on Outlook.com, or a Microsoft work or school account.
- ngrok A secure agent to proxy your development environment onto the internet.
If you don't have a Microsoft account, there are a couple of options to get a free account:
- You can sign up for a new personal Microsoft account.
- You can sign up for the Office 365 Developer Program to get a free Office 365 subscription.
As this exercise requires new permissions the App Registration needs to be updated to include the Calendar.Read permission using the new Azure AD Portal App Registrations UI (in preview as of the time of publish Nov 2018).
-
Open a browser and navigate to the Preview App Registration within Azure AD Portal. Login using a personal account (aka: Microsoft Account) or Work or School Account with permissions to create app registrations.
Note: If you do not have permissions to create app registrations contact your Azure AD domain administrators.
-
Click on the .NET Core Graph Tutorial item in the list
Note: If you used a different name while completing the Base Console Application Setup select that instead.
-
Click API permissions from the current blade content.
-
Back on the API permissions content blade, click Grant admin consent for <name of tenant>.
- Click Yes.
In this step you will create an ASP.Net core Web API project and set up the necessary dependencies.
Create a folder called GraphWebhooks
for the console application.
Note: For the purposes of this sample the project folder was named GraphWebhooks. If you choose a different folder name ensure that the namespace for files matches.
-
Open the command line and navigate to this folder. Run the following command:
dotnet new webapi
-
Before moving on, install the following NuGet packages that you will use later.
- Microsoft.Identity.Client
- Microsoft.Graph
- Microsoft.Extensions.Configuration
- Microsoft.Extensions.Configuration.Json
Run the following commands to install these NuGet packages:
dotnet add package Microsoft.Identity.Client --version 2.3.1-preview dotnet add package Microsoft.Graph dotnet add package Microsoft.Extensions.Configuration dotnet add package Microsoft.Extensions.Configuration.Json
In this step you will set up the classes and configuration that will be used to authenticate requests to Microsoft Graph
-
On the command line from Step 2, run the following command inside the project folder to open Visual Studio Code with the project folder opened:
code .
-
Rename the
appsettings.example.json
file toappsettings.json
and open it. -
Fill in the appropriate values for the following settings in the
appsettings.json
file. These can be transscribed from the appsettings that were used for the console application from day 16."applicationId": "", "applicationSecret": "", "tenantId": "", "redirectUri": "", "baseUrl": ""
Note:
baseUrl
will be filled in at a later step. Leave it empty for now. -
Open the
Startup.cs
file add a new method calledValidateConfig()
:private void ValidateConfig() { // Validate required settings if (string.IsNullOrEmpty(Configuration["applicationId"]) || string.IsNullOrEmpty(Configuration["applicationSecret"]) || string.IsNullOrEmpty(Configuration["redirectUri"]) || string.IsNullOrEmpty(Configuration["tenantId"]) || string.IsNullOrEmpty(Configuration["baseUrl"])) { throw new ApplicationException("The configuration is invalid, are you missing some keys?"); } }
-
Edit the
Configure
method so that it matches this listing:public void Configure(IApplicationBuilder app, IHostingEnvironment env) { ValidateConfig(); if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseHsts(); app.UseHttpsRedirection(); } app.UseMvc(); }
Note: that UseHttpsRedirection is only called for production configurations, this is to allow ngrok to call the http endpoint and not encounter SSL validation issues when running on localhost.
-
Configure the ngrok tunnel and settings:
-
Open a separate command line window.
-
Run the folowing to start your ngrok tunnel
ngrok http 5000
Note: If you did not yet install the ngrok tool from the prerequisites please do so now. Be sure to place the
ngrok.exe
tool in an accesible location or update your PATH environment variable to include the containing folder. -
In VSCode Paste the copied URL into the baseUrl property in
appsettings.json
-
-
Add a folder called
Helpers
-
In the
Helpers
folder create a file calledAuthHander.cs
with this listing:using System; using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; using Microsoft.Identity.Client; using Microsoft.Graph; using Microsoft.Extensions.Configuration; using System.Linq; using System.Threading; namespace GraphWebhooks { // This class allows an implementation of IAuthenticationProvider to be inserted into the DelegatingHandler // pipeline of an HttpClient instance. In future versions of GraphSDK, many cross-cutting concerns will // be implemented as DelegatingHandlers. This AuthHandler will come in the box. public class AuthHandler : DelegatingHandler { private IAuthenticationProvider _authenticationProvider; public AuthHandler(IAuthenticationProvider authenticationProvider, HttpMessageHandler innerHandler) { InnerHandler = innerHandler; _authenticationProvider = authenticationProvider; } protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { await _authenticationProvider.AuthenticateRequestAsync(request); return await base.SendAsync(request, cancellationToken); } } }
-
In the
Handlers
folder create a file calledMsalAuthenticationProvider.cs
with this listing:using System; using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; using Microsoft.Identity.Client; using Microsoft.Graph; using Microsoft.Extensions.Configuration; using System.Linq; namespace GraphWebhooks { // This class encapsulates the details of getting a token from MSAL and exposes it via the // IAuthenticationProvider interface so that GraphServiceClient or AuthHandler can use it. // A significantly enhanced version of this class will in the future be available from // the GraphSDK team. It will supports all the types of Client Application as defined by MSAL. public class MsalAuthenticationProvider : IAuthenticationProvider { private ConfidentialClientApplication _clientApplication; private string[] _scopes; public MsalAuthenticationProvider(ConfidentialClientApplication clientApplication, string[] scopes) { _clientApplication = clientApplication; _scopes = scopes; } /// <summary> /// Update HttpRequestMessage with credentials /// </summary> public async Task AuthenticateRequestAsync(HttpRequestMessage request) { var token = await GetTokenAsync(); request.Headers.Authorization = new AuthenticationHeaderValue("bearer", token); } /// <summary> /// Acquire Token /// </summary> public async Task<string> GetTokenAsync() { AuthenticationResult authResult = null; authResult = await _clientApplication.AcquireTokenForClientAsync(_scopes); return authResult.AccessToken; } } }
-
In the
Helpers
folder create a file calledMsalAuthenticationProviderFactory.cs
with the following listing:using System.Collections.Generic; using System.Net.Http; using Microsoft.Extensions.Configuration; using Microsoft.Graph; using Microsoft.Identity.Client; namespace GraphWebhooks { // This class encapsulates the details of getting a token from MSAL and exposes it via the // IAuthenticationProvider interface so that GraphServiceClient or AuthHandler can use it. // A significantly enhanced version of this class will in the future be available from // the GraphSDK team. It will supports all the types of Client Application as defined by MSAL. public class GraphHttpClientFactory { public static HttpClient GetAuthenticatedHTTPClient(IConfiguration config) { var authenticationProvider = BuildAuthProvider(config); return new HttpClient(new AuthHandler(authenticationProvider, new HttpClientHandler())); } private static IAuthenticationProvider BuildAuthProvider(IConfiguration config) { var clientId = config["applicationId"]; var clientSecret = config["applicationSecret"]; var redirectUri = config["redirectUri"]; var authority = $"https://login.microsoftonline.com/{config["tenantId"]}/v2.0"; //this specific scope means that application will default to what is defined in the application registration rather than using dynamic scopes List<string> scopes = new List<string>(); scopes.Add("https://graph.microsoft.com/.default"); var cca = new ConfidentialClientApplication(clientId, authority, redirectUri, new ClientCredential(clientSecret), null, null); return new MsalAuthenticationProvider(cca, scopes.ToArray()); } } }
-
In
Startup.cs
edit theConfigureServices
method and add the following line:services.AddSingleton<HttpClient>(GraphHttpClientFactory.GetAuthenticatedHTTPClient(Configuration));
This registers an instance of the HttpClient class pre-configured with the necessary authentication context with the Dependency Injection system that is baked into ASP.NET Core.
In this step you will create the classes to serialize and deserialize all of the requests needed when working with webhooks and create an in-memory repository for tracking subscriptions.
-
Add a folder called
Models
-
In the
Models
folder create a new file calledNotificationUrl.cs
with the following listing:namespace GraphWebhooks { public class NotificationUrl { public string Url { get; set; } } }
-
In the
Models
folder create a new file calledResourceData.cs
with the following listing:using Newtonsoft.Json; namespace GraphWebhooks { public class ResourceData { [JsonProperty("@odata.type")] public string OdataType { get; set; } [JsonProperty("@odata.id")] public string OdataId { get; set; } [JsonProperty("@odata.etag")] public string OdataEtag { get; set; } [JsonProperty("id")] public string Id { get; set; } } }
-
In the
Models
folder create a new file calledNotification.cs
with the following listing:using System; using Microsoft.AspNetCore.Mvc.ModelBinding; using Newtonsoft.Json; namespace GraphWebhooks { public class Notification { [JsonProperty("subscriptionId")] public string SubscriptionId { get; set; } [JsonProperty("subscriptionExpirationDateTime")] public DateTimeOffset SubscriptionExpirationDateTime { get; set; } [JsonProperty("clientState")] public string ClientState { get; set; } [JsonProperty("changeType")] public string ChangeType { get; set; } [JsonProperty("resource")] public string Resource { get; set; } [JsonProperty("resourceData")] public ResourceData ResourceData { get; set; } } }
-
In the
Models
folder create a new file calledNotifications.cs
with the following listing:using System.Collections.Generic; using Newtonsoft.Json; namespace GraphWebhooks { public class Notifications { [JsonProperty("value")] public IEnumerable<Notification> value { get; set; } } }
-
Create a folder called
Repositories
-
In the
Repositories
folder create a file calledSubscriptionRepository.cs
with the following listing:using System; using System.Collections.Generic; using System.Linq; using Microsoft.Graph; using Newtonsoft.Json; namespace GraphWebhooks { public interface ISubscriptionRepository { void Save(Subscription subscription); void Delete(string id); Subscription Load(string id); Subscription LoadByUpn(string upn); } public class SubscriptionRepository : ISubscriptionRepository { private IList<Subscription> _subscriptions; public SubscriptionRepository() { _subscriptions = new List<Subscription>(); } public void Save(Subscription subscription) => _subscriptions.Add(subscription); public void Delete(string id) { var toDelete = Load(id); _subscriptions.Remove(toDelete); } public Subscription Load(string id) => _subscriptions.FirstOrDefault(s => s.Id == id); public Subscription LoadByUpn(string upn) => _subscriptions.FirstOrDefault(s => s.Resource.Contains($"/{upn}/")); } }
In a production system the
SubscriptionRepository
implementation would certainly be connected to some form of cloud storage system such as CosmosDb or Azure SQL. -
In the
Startup.cs
file edit theConfigureServices
method to add the following lines:services.AddSingleton<ISubscriptionRepository>(new SubscriptionRepository()); services.AddSingleton<NotificationUrl>(new NotificationUrl { Url = $"{Configuration["baseUrl"]}/api/notifications" });
In this step you will define the Subscription and Notifcation controllers to handle the requests required to register subscriptions and handle notifications.
-
In the
Helpers
folder create a new file calledHttpRequestExtensions.cs
with the following listing:using System.IO; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Newtonsoft.Json; namespace GraphWebhooks.Controllers { public static class HttpRequestExtensions { /// <summary> /// Convert the Request.Body stream into an object of T /// </summary> /// <param name="request">Request instance to apply to</param> /// <param name="encoding">Optional - Encoding, defaults to UTF8</param> /// <returns></returns> public static async Task<T> GetBodyAsync<T>(this HttpRequest request, Encoding encoding = null) { if (encoding == null) encoding = Encoding.UTF8; using (StreamReader reader = new StreamReader(request.Body, encoding)) { return JsonConvert.DeserializeObject<T>(await reader.ReadToEndAsync()); } } } }
This extension method is necessary as it's not possible in ASP.NET Core to have a both FromQuery and FromBody parameters on a single method where only one parameter is supplied.
-
In the
Controllers
folder delete the existingValuesController.cs
file -
In the
Controllers
folder add a file called SubscriptionsController with the following listing:using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Graph; using Newtonsoft.Json; namespace GraphWebhooks.Controllers { [Route("api/[controller]")] [ApiController] public class SubscriptionsController : ControllerBase { private const string _subscriptionsResource = "https://graph.microsoft.com/v1.0/subscriptions"; private readonly ISubscriptionRepository _subscriptionRepository; private readonly HttpClient _graphClient; private readonly NotificationUrl _notificationUrl; public SubscriptionsController(ISubscriptionRepository subscriptionRepository, HttpClient graphClient, NotificationUrl notificationUrl) { if (subscriptionRepository == null) throw new ArgumentNullException(nameof(subscriptionRepository)); if (graphClient == null) throw new ArgumentNullException(nameof(graphClient)); if (notificationUrl == null) throw new ArgumentNullException(nameof(notificationUrl)); _subscriptionRepository = subscriptionRepository; _graphClient = graphClient; _notificationUrl = notificationUrl; } // GET api/subscriptions/[email protected] [HttpGet("{upn}")] public async Task<ActionResult<Subscription>> Get(string upn) { var result = _subscriptionRepository.LoadByUpn(upn); if (result != null && result.ExpirationDateTime > DateTime.Now) { return result; } string clientState = Guid.NewGuid().ToString("d"); var request = new Subscription { ChangeType = "created", ExpirationDateTime = DateTime.Now.AddDays(2), ClientState = clientState, Resource = $"users/{upn}/events", NotificationUrl = _notificationUrl.Url }; var response = await _graphClient.PostAsJsonAsync(_subscriptionsResource, request); string responseBody = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { Console.WriteLine(response.ReasonPhrase); var error = new ObjectResult(responseBody); error.StatusCode = 500; return error; } Subscription subscription = JsonConvert.DeserializeObject<Subscription>(responseBody); _subscriptionRepository.Save(subscription); return subscription; } // DELETE api/subscriptions/a7aebd9c-1f8b-41a0-a973-47b7296975c3 [HttpDelete("{id}")] public async Task<IActionResult> Delete(string id) { var response = await _graphClient.DeleteAsync($"{_subscriptionsResource}/{id}"); string responseBody = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { Console.WriteLine(response.ReasonPhrase); var error = new ObjectResult(responseBody); error.StatusCode = 500; return error; } _subscriptionRepository.Delete(id); return new StatusCodeResult(204); } } }
The Subscriptions controller allows a developer to register a subscription for calendar events. For convienence it also provides a DELETE method to remove any subscription that have been created.
-
In the
Controllers
folder create a new fileNotificationsController.cs
with the following listing:using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Graph; using Newtonsoft.Json; namespace GraphWebhooks.Controllers { [Route("api/[controller]")] [ApiController] public class NotificationsController : ControllerBase { private readonly ISubscriptionRepository _subscriptionRepository; private readonly HttpClient _graphClient; public NotificationsController(ISubscriptionRepository subscriptionRepository, HttpClient graphClient) { if (subscriptionRepository == null) throw new ArgumentNullException(nameof(subscriptionRepository)); if (graphClient == null) throw new ArgumentNullException(nameof(graphClient)); _subscriptionRepository = subscriptionRepository; _graphClient = graphClient; } [HttpPost] public async Task<ActionResult> Listen([FromQuery] string validationToken) { if (!string.IsNullOrEmpty(validationToken)) { return Content(validationToken, "plain/text"); } try { // Read the post body directly as we can't mix optional FromBody and FromQuery parameters var postBody = await Request.GetBodyAsync<Notifications>(); foreach (var item in postBody.value) { await ProcessEventNotification(item); } } catch (Exception) { // Just ignore exceptions } // Send a 202 so MicrosoftGraph knows we processed the notification return new StatusCodeResult(202); } private async Task ProcessEventNotification(Notification item) { var subscription = _subscriptionRepository.Load(item.SubscriptionId); // We should only process requests for which we have ClientState stored if (subscription != null && item.ClientState == subscription.ClientState) { Uri Uri = new Uri($"https://graph.microsoft.com/v1.0/{item.Resource}"); var httpResult = await _graphClient.GetStringAsync(Uri); var calendarEvent = JsonConvert.DeserializeObject<Event>(httpResult); // Do processing of your subscribed entity Console.WriteLine(httpResult); if (string.IsNullOrWhiteSpace(calendarEvent.BodyPreview)) { // Decline the meeting as it has no agenda } } } } }
The NotificationsController is responsible for recieving notfication requests from Microsoft Graph
The Web API is now able to register subscriptions and handle incoming notifications. In order to test the Web API run the following commands from the command line:
-
Save all files.
dotnet build dotnet run
-
From the command line running ngrok copy the https forwading url.
-
Open a web browser at
<ngrokUrl>/api/subscriptions/<known-upn>
, ex. https://891b8419.ngrok.io/api/subscriptions/[email protected]. -
Send a meeting request to the email address used to create the subscription
-
You will see another api request to the NotificationsController with an http 202 result and some information logged to the console.
This completes the exercise to set up a Web API for registering subscriptions and handing notifications. The overall flow and chain of actions shown here is the same pattern no matter what notifications you wish to recieve or which language you use to implement your solution.