Build Azure Functions with Microsoft Graph
This tutorial teaches you how to build an Azure Function that uses the Microsoft Graph API to retrieve calendar information for a user.
Tip
If you prefer to just download the completed tutorial, you can download or clone the GitHub repository. See the README file in the demo folder for instructions on configuring the app with an app ID and secret.
Prerequisites
Before you start this tutorial, you should have the following tools installed on your development machine.
You should also have a Microsoft work or school account, with access to a global administrator account in the same organization. If you don't have a Microsoft account, you can sign up for the Microsoft 365 Developer Program to get a free Office 365 subscription.
Note
This tutorial was written with the following versions of the above tools. The steps in this guide may work with other versions, but that has not been tested.
- .NET Core SDK 5.0.203
- Azure Functions Core Tools 3.0.3442
- Azure CLI 2.23.0
- ngrok 2.3.40
Feedback
Please provide any feedback on this tutorial in the GitHub repository.
Create an Azure Functions project
In this tutorial, you will create a simple Azure Function that implements HTTP trigger functions that call Microsoft Graph. These functions will cover the following scenarios:
- Implements an API to access a user's inbox using on-behalf-of flow authentication.
- Implements an API to subscribe and unsubscribe for notifications on a user's inbox, using using client credentials grant flow authentication.
- Implements a webhook to receive change notifications from Microsoft Graph and access data using client credentials grant flow.
You will also create a simple JavaScript single-page application (SPA) to call the APIs implemented in the Azure Function.
Create Azure Functions project
Open your command-line interface (CLI) in a directory where you want to create the project. Run the following command.
func init GraphTutorial --worker-runtime dotnetisolatedChange the current directory in your CLI to the GraphTutorial directory and run the following commands to create three functions in the project.
func new --name GetMyNewestMessage --template "HTTP trigger" func new --name SetSubscription --template "HTTP trigger" func new --name Notify --template "HTTP trigger"Open local.settings.json and add the following to the file to allow CORS from
http://localhost:8080, the URL for the test application."Host": { "CORS": "http://localhost:8080" }Run the following command to run the project locally.
func startIf everything is working, you will see the following output:
Functions: GetMyNewestMessage: [GET,POST] http://localhost:7071/api/GetMyNewestMessage Notify: [GET,POST] http://localhost:7071/api/Notify SetSubscription: [GET,POST] http://localhost:7071/api/SetSubscriptionVerify that the functions are working correctly by opening your browser and browsing to the function URLs shown in the output. You should see the following message in your browser:
Welcome to Azure Functions!.
Create single-page application
Open your CLI in a directory where you want to create the project. Create a directory named TestClient to hold your HTML and JavaScript files.
Create a new file named index.html in the TestClient directory and add the following code.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"> <title>Azure Functions Graph Tutorial Test Client</title> <link rel="shortcut icon" href="g-raph.png"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/css/bootstrap.min.css" integrity="sha384-wEmeIV1mKuiNpC+IOBjI7aAzPcEZeedi5yW5f2yOq55WWLwNGmvvx4Um1vskeMj0" crossorigin="anonymous"> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.12.1/css/all.css" crossorigin="anonymous"> <link href="style.css" rel="stylesheet" type="text/css" /> </head> <body> <nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark"> <div class="container"> <a href="/" class="navbar-brand">Azure Functions Graph Test Client</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarCollapse"> <ul id="authenticated-nav" class="navbar-nav me-auto flex-grow-1"></ul> <ul class="navbar-nav"> <li class="nav-item"> <a class="nav-link" href="https://docs.microsoft.com/graph/overview" target="_blank"> <i class="fas fa-external-link-alt me-1"></i>Docs </a> </li> <li id="account-nav" class="nav-item"></li> </ul> </div> </div> </nav> <main id="main-container" role="main" class="container"> </main> <!-- Bootstrap --> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-p34f1UUtsS3wqzfto5wAAmdvj+osOnFyQFpp4Ua3gs/ZVWx6oOypYoCJhGGScy+8" crossorigin="anonymous"></script> <!-- MSAL --> <script src="https://alcdn.msauth.net/browser/2.14.2/js/msal-browser.min.js" integrity="sha384-ggh+EF1aSqm+Y4yvv2n17KpurNcZTeYtUZUvhPziElsstmIEubyEB6AIVpKLuZgr" crossorigin="anonymous"></script> <script src="config.js"></script> <script src="ui.js"></script> <script src="auth.js"></script> <script src="azurefunctions.js"></script> </body> </html>This defines the basic layout of the app, including a navigation bar. It also adds the following:
- Bootstrap and its supporting JavaScript
- FontAwesome
- Microsoft Authentication Library for JavaScript (MSAL.js) 2.0
Tip
The page includes a favicon, (
<link rel="shortcut icon" href="g-raph.png">). You can remove this line, or you can download the g-raph.png file from GitHub.Create a new file named style.css in the TestClient directory and add the following code.
body { padding-top: 70px; }Create a new file named ui.js in the TestClient directory and add the following code.
// Select DOM elements to work with const authenticatedNav = document.getElementById('authenticated-nav'); const accountNav = document.getElementById('account-nav'); const mainContainer = document.getElementById('main-container'); const Views = { error: 1, home: 2, message: 3, subscriptions: 4 }; // Helper function to create an element, set class, and add text function createElement(type, className, text) { const element = document.createElement(type); element.className = className; if (text) { const textNode = document.createTextNode(text); element.appendChild(textNode); } return element; } // Show the navigation items that should only show if // the user is signed in function showAuthenticatedNav(user, view) { authenticatedNav.innerHTML = ''; if (user) { // Add message link const messageNav = createElement('li', 'nav-item'); const messageLink = createElement('button', `btn btn-link nav-link${view === Views.message ? ' active' : '' }`, 'Latest Message'); messageLink.setAttribute('onclick', 'getLatestMessage();'); messageNav.appendChild(messageLink); authenticatedNav.appendChild(messageNav); // Add subscriptions link const subscriptionNav = createElement('li', 'nav-item'); const subscriptionLink = createElement('button', `btn btn-link nav-link${view === Views.message ? ' active' : '' }`, 'Subscriptions'); subscriptionLink.setAttribute('onclick', `updatePage(${Views.subscriptions});`); subscriptionNav.appendChild(subscriptionLink); authenticatedNav.appendChild(subscriptionNav); } } // Show the sign in button or the dropdown to sign-out function showAccountNav(user) { accountNav.innerHTML = ''; if (user) { // Show the "signed-in" nav accountNav.className = 'nav-item dropdown'; const dropdown = createElement('a', 'nav-link dropdown-toggle'); dropdown.setAttribute('data-bs-toggle', 'dropdown'); dropdown.setAttribute('role', 'button'); accountNav.appendChild(dropdown); const userIcon = createElement('i', 'far fa-user-circle fa-lg rounded-circle align-self-center'); userIcon.style.width = '32px'; dropdown.appendChild(userIcon); const menu = createElement('div', 'dropdown-menu dropdown-menu-end'); accountNav.appendChild(menu); const userName = createElement('h5', 'dropdown-item-text mb-0', user); menu.appendChild(userName); const divider = createElement('div', 'dropdown-divider'); menu.appendChild(divider); const signOutButton = createElement('button', 'dropdown-item', 'Sign out'); signOutButton.setAttribute('onclick', 'signOut();'); menu.appendChild(signOutButton); } else { // Show a "sign in" button accountNav.className = 'nav-item'; const signInButton = createElement('button', 'btn btn-link nav-link', 'Sign in'); signInButton.setAttribute('onclick', 'signIn();'); accountNav.appendChild(signInButton); } } // Renders the home view function showWelcomeMessage(user) { // Create jumbotron const jumbotron = createElement('div', 'p-5 mb-4 bg-light rounded-5') const jumbotronContainer = createElement('div', 'container-fluid py-5'); jumbotron.appendChild(jumbotronContainer); const heading = createElement('h1', null, 'Azure Functions Graph Tutorial Test Client'); jumbotronContainer.appendChild(heading); const lead = createElement('p', 'lead', 'This sample app is used to test the Azure Functions in the Azure Functions Graph Tutorial'); jumbotronContainer.appendChild(lead); if (user) { // Welcome the user by name const welcomeMessage = createElement('h4', null, `Welcome ${user}!`); jumbotronContainer.appendChild(welcomeMessage); const callToAction = createElement('p', null, 'Use the navigation bar at the top of the page to get started.'); jumbotronContainer.appendChild(callToAction); } else { // Show a sign in button in the jumbotron const signInButton = createElement('button', 'btn btn-primary btn-large', 'Click here to sign in'); signInButton.setAttribute('onclick', 'signIn();') jumbotronContainer.appendChild(signInButton); } mainContainer.innerHTML = ''; mainContainer.appendChild(jumbotron); } // Renders an email message function showLatestMessage(message) { if (!message) { const noMessage = createElement('h1', 'mt-3', 'No messages in your inbox'); mainContainer.innerHTML = ''; mainContainer.appendChild(noMessage); return; } // Show message const messageCard = createElement('div', 'card'); const cardBody = createElement('div', 'card-body'); messageCard.appendChild(cardBody); const subject = createElement('h1', 'card-title', `${message.subject || '(No subject)'}`); cardBody.appendChild(subject); const fromLine = createElement('div', 'd-flex'); cardBody.appendChild(fromLine); const fromLabel = createElement('div', 'me-3'); fromLabel.appendChild(createElement('strong', '', 'From:')); fromLine.appendChild(fromLabel); fromLine.appendChild(createElement('div', '', message.from.emailAddress.name)); const receivedLine = createElement('div', 'd-flex'); cardBody.appendChild(receivedLine); const receivedLabel = createElement('div', 'me-3'); receivedLabel.appendChild(createElement('strong', '', 'Received:')); receivedLine.appendChild(receivedLabel); receivedLine.appendChild(createElement('div', '', message.receivedDateTime)); mainContainer.innerHTML = ''; mainContainer.appendChild(messageCard); } // Renders current subscriptions from the session, and allows the user // to add new subscriptions function showSubscriptions() { const subscriptions = JSON.parse(sessionStorage.getItem('graph-subscriptions')); // Show new subscription form const form = createElement('form', 'form-inline mb-3'); const userInput = createElement('input', 'form-control mb-2 me-2 flex-grow-1'); userInput.setAttribute('id', 'subscribe-user'); userInput.setAttribute('type', 'text'); userInput.setAttribute('placeholder', 'User to subscribe to (user ID or UPN)'); form.appendChild(userInput); const subscribeButton = createElement('button', 'btn btn-primary mb-2', 'Subscribe'); subscribeButton.setAttribute('type', 'button'); subscribeButton.setAttribute('onclick', 'createSubscription();'); form.appendChild(subscribeButton); const card = createElement('div', 'card'); const cardBody = createElement('div', 'card-body'); card.appendChild(cardBody); cardBody.appendChild(createElement('h2', 'card-title mb-4', 'Existing subscriptions')); const subscriptionTable = createElement('table', 'table'); cardBody.appendChild(subscriptionTable); const thead = createElement('thead', ''); subscriptionTable.appendChild(thead); const theadRow = createElement('tr', ''); thead.appendChild(theadRow); theadRow.appendChild(createElement('th', '')); theadRow.appendChild(createElement('th', '', 'User')); theadRow.appendChild(createElement('th', '', 'Subscription ID')) if (subscriptions) { // List subscriptions for (const subscription of subscriptions) { const row = createElement('tr', ''); subscriptionTable.appendChild(row); const deleteButtonCell = createElement('td', 'py-2'); row.appendChild(deleteButtonCell); const deleteButton = createElement('button', 'btn btn-sm btn-outline-primary', 'Delete'); deleteButton.setAttribute('onclick', `deleteSubscription("${subscription.subscriptionId}");`); deleteButtonCell.appendChild(deleteButton); row.appendChild(createElement('td', '', subscription.userId)); row.appendChild(createElement('td', '', subscription.subscriptionId)); } } mainContainer.innerHTML = ''; mainContainer.appendChild(form); mainContainer.appendChild(card); } // Renders an error function showError(error) { const alert = createElement('div', 'alert alert-danger'); const message = createElement('p', 'mb-3', error.message); alert.appendChild(message); if (error.debug) { const pre = createElement('pre', 'alert-pre border bg-light p-2'); alert.appendChild(pre); const code = createElement('code', 'text-break text-wrap', JSON.stringify(error.debug, null, 2)); pre.appendChild(code); } mainContainer.innerHTML = ''; mainContainer.appendChild(alert); } // Re-renders the page with the selected view function updatePage(view, data) { if (!view) { view = Views.home; } // Get the user name from the session const user = sessionStorage.getItem('msal-userName'); if (!user && view !== Views.error) { view = Views.home; } showAccountNav(user); showAuthenticatedNav(user, view); switch (view) { case Views.error: showError(data); break; case Views.home: showWelcomeMessage(user); break; case Views.message: showLatestMessage(data); break; case Views.subscriptions: showSubscriptions(); break; } } updatePage(Views.home);This code uses JavaScript to render the current page based on the selected view.
Test the single-page application
Note
This section includes instructions for using dotnet-serve to run a simple testing HTTP server on your development machine. Using this specific tool is not required. You can use any testing server you prefer to serve the TestClient directory.
Run the following command in your CLI to install dotnet-serve.
dotnet tool install --global dotnet-serveChange the current directory in your CLI to the TestClient directory and run the following command to start an HTTP server.
dotnet serve -h "Cache-Control: no-cache, no-store, must-revalidate" -p 8080Open your browser and navigate to
http://localhost:8080. The page should render, but none of the buttons currently work.
Add NuGet packages
Before moving on, install some additional NuGet packages that you will use later.
- Microsoft.Azure.Functions.Extensions to enable dependency injection in the Azure Functions project.
- Microsoft.Extensions.Configuration.UserSecrets to read application configuration from the .NET development secret store.
- Microsoft.Graph for making calls to Microsoft Graph.
- Microsoft.Identity.Client for authenticating and managing tokens.
- Microsoft.IdentityModel.Protocols.OpenIdConnect for retrieving OpenID configuration for token validation.
- System.IdentityModel.Tokens.Jwt for validating tokens sent to the web API.
Change the current directory in your CLI to the GraphTutorial directory and run the following commands.
dotnet add package Microsoft.Azure.Functions.Extensions --version 1.1.0 dotnet add package Microsoft.Extensions.Configuration.UserSecrets --version 5.0.0 dotnet add package Microsoft.Graph --version 4.0.0 dotnet add package Microsoft.Identity.Client --version 4.35.1 dotnet add package Microsoft.IdentityModel.Protocols.OpenIdConnect --version 6.12.0 dotnet add package System.IdentityModel.Tokens.Jwt --version 6.12.0
Register the apps in the portal
In this exercise you will create three new Azure AD applications using the Azure Active Directory admin center:
- An app registration for the single-page application so that it can sign in users and get tokens allowing the application to call the Azure Function.
- An app registration for the Azure Function that allows it to use the on-behalf-of flow to exchange the token sent by the SPA for a token that will allow it to call Microsoft Graph.
- An app registration for the Azure Function webhook that allows it to use the client credential flow to call Microsoft Graph without a user.
Note
This example requires three app registrations because it is implementing both the on-behalf-of flow and the client credential flow. If your Azure Function only uses one of these flows, you would only need to create the app registrations that correspond to that flow.
Open a browser and navigate to the Azure Active Directory admin center and login using an Microsoft 365 tenant organization admin.
Select Azure Active Directory in the left-hand navigation, then select App registrations under Manage.

Register an app for the single-page application
Select New registration. On the Register an application page, set the values as follows.
- Set Name to
Graph Azure Function Test App. - Set Supported account types to Accounts in this organizational directory only.
- Under Redirect URI, change the dropdown to Single-page application (SPA) and set the value to
http://localhost:8080.

- Set Name to
Select Register. On the Graph Azure Function Test App page, copy the values of the Application (client) ID and Directory (tenant) ID and save them, you will need them in the later steps.

Register an app for the Azure Function
Return to App Registrations, and select New registration. On the Register an application page, set the values as follows.
- Set Name to
Graph Azure Function. - Set Supported account types to Accounts in this organizational directory only.
- Leave Redirect URI blank.
- Set Name to
Select Register. On the Graph Azure Function page, copy the value of the Application (client) ID and save it, you will need it in the next step.
Select Certificates & secrets under Manage. Select the New client secret button. Enter a value in Description and select one of the options for Expires and select Add.

Copy the client secret value before you leave this page. You will need it in the next step.
Important
This client secret is never shown again, so make sure you copy it now.

Select API Permissions under Manage. Choose Add a permission.
Select Microsoft Graph, then Delegated Permissions. Add Mail.Read and select Add permissions.

Select Expose an API under Manage, then choose Add a scope.
Accept the default Application ID URI and choose Save and continue.
Fill in the Add a scope form as follows:
- Scope name: Mail.Read
- Who can consent?: Admins and users
- Admin consent display name: Read all users' inboxes
- Admin consent description: Allows the app to read all users' inboxes
- User consent display name: Read your inbox
- User consent description: Allows the app to read your inbox
- State: Enabled
Select Add scope.
Copy the new scope, you'll need it in later steps.

Select Manifest under Manage.
Locate
knownClientApplicationsin the manifest, and replace it's current value of[]with[TEST_APP_ID], whereTEST_APP_IDis the application ID of the Graph Azure Function Test App app registration. Select Save.
Note
Adding the test application's app ID to the knownClientApplications property in the Azure Function's manifest allows the test application to trigger a combined consent flow. This is necessary for the on-behalf-of flow to work.
Add Azure Function scope to test application registration
Return to the Graph Azure Function Test App registration, and select API Permissions under Manage. Select Add a permission.
Select My APIs, then select Load more. Select Graph Azure Function.

Select the Mail.Read permission, then select Add permissions.
In the Configured permissions, remove the User.Read permission under Microsoft Graph by selecting the ... to the right of the permission and selecting Remove permission. Select Yes, remove to confirm.

Register an app for the Azure Function webhook
Return to App Registrations, and select New registration. On the Register an application page, set the values as follows.
- Set Name to
Graph Azure Function Webhook. - Set Supported account types to Accounts in this organizational directory only.
- Leave Redirect URI blank.
- Set Name to
Select Register. On the Graph Azure Function webhook page, copy the value of the Application (client) ID and save it, you will need it in the next step.
Select Certificates & secrets under Manage. Select the New client secret button. Enter a value in Description and select one of the options for Expires and select Add.
Copy the client secret value before you leave this page. You will need it in the next step.
Select API Permissions under Manage. Choose Add a permission.
Select Microsoft Graph, then Application Permissions. Add User.Read.All and Mail.Read, then select Add permissions.
In the Configured permissions, remove the delegated User.Read permission under Microsoft Graph by selecting the ... to the right of the permission and selecting Remove permission. Select Yes, remove to confirm.
Select the Grant admin consent for... button, then select Yes to grant admin consent for the configured application permissions. The Status column in the Configured permissions table changes to Granted for ....

Implement the API with on-behalf-of authentication
In this exercise you will finish implementing the Azure Function GetMyNewestMessage and update the test client to call the function.
The Azure Function uses the on-behalf-of flow. The basic order of events in this flow are:
- The test application uses an interactive auth flow to allow the user to sign in and grant consent. It gets back a token that is scoped to the Azure Function. The token does NOT contain any Microsoft Graph scopes.
- The test application invokes the Azure Function, sending its access token in the
Authorizationheader. - The Azure Function validates the token, then exchanges that token for a second access token that contains Microsoft Graph scopes.
- The Azure Function calls Microsoft Graph on the user's behalf using the second access token.
Important
To avoid storing the application ID and secret in source, you will use the .NET Secret Manager to store these values. The Secret Manager is for development purposes only, production apps should use a trusted secret manager for storing secrets.
Add authentication to the single page application
Start by adding authentication to the SPA. This will allow the application to get an access token granting access to call the Azure Function. Because this is a SPA, it will use the authorization code flow with PKCE.
Create a new file in the TestClient directory named config.js and add the following code.
const msalConfig = { auth: { clientId: 'YOUR_TEST_APP_APP_ID_HERE', authority: 'https://login.microsoftonline.com/YOUR_TENANT_ID_HERE' } }; const msalRequest = { // Scope of the Azure Function scopes: [ 'YOUR_AZURE_FUNCTION_APP_ID_HERE/.default' ] }Replace
YOUR_TEST_APP_APP_ID_HEREwith the application ID you created in the Azure portal for the Graph Azure Function Test App. ReplaceYOUR_TENANT_ID_HEREwith the Directory (tenant) ID value you copied from the Azure portal. ReplaceYOUR_AZURE_FUNCTION_APP_ID_HEREwith the application ID for the Graph Azure Function.Important
If you're using source control such as git, now would be a good time to exclude the config.js file from source control to avoid inadvertently leaking your app IDs and tenant ID.
Create a new file in the TestClient directory named auth.js and add the following code.
// Create the main MSAL instance // configuration parameters are located in config.js const msalClient = new msal.PublicClientApplication(msalConfig); async function signIn() { // Login try { // Use MSAL to login const authResult = await msalClient.loginPopup(msalRequest); // Save the account username, needed for token acquisition sessionStorage.setItem('msal-userName', authResult.account.username); // Refresh home page updatePage(Views.home); } catch (error) { console.log(error); updatePage(Views.error, { message: 'Error logging in', debug: error }); } } function signOut() { account = null; sessionStorage.removeItem('msal-userName'); msalClient.logout(); }Consider what this code does.
- It initializes a
PublicClientApplicationusing the values stored in config.js. - It uses
loginPopupto sign the user in, using the permission scope for the Azure Function. - It stores the user's username in the session.
Important
Since the app uses
loginPopup, you may need to change your browser's pop-up blocker to allow pop-ups fromhttp://localhost:8080.- It initializes a
Refresh the page and sign in. The page should update with the user name, indicating that the sign in was successful.
Add authentication to the Azure Function
In this section you'll implement the on-behalf-of flow in the GetMyNewestMessage Azure Function to get an access token compatible with Microsoft Graph.
Initialize the .NET development secret store by opening your CLI in the directory that contains GraphTutorial.csproj and running the following command.
dotnet user-secrets initAdd your application ID, secret, and tenant ID to the secret store using the following commands. Replace
YOUR_API_FUNCTION_APP_ID_HEREwith the application ID for the Graph Azure Function. ReplaceYOUR_API_FUNCTION_APP_SECRET_HEREwith the application secret you created in the Azure portal for the Graph Azure Function. ReplaceYOUR_TENANT_ID_HEREwith the Directory (tenant) ID value you copied from the Azure portal.dotnet user-secrets set apiFunctionId "YOUR_API_FUNCTION_APP_ID_HERE" dotnet user-secrets set apiFunctionSecret "YOUR_API_FUNCTION_APP_SECRET_HERE" dotnet user-secrets set tenantId "YOUR_TENANT_ID_HERE"
Process the incoming bearer token
In this section you'll implement a class to validate and process the bearer token sent from the SPA to the Azure Function.
Create a new directory in the GraphTutorial directory named Authentication.
Create a new file named TokenValidationResult.cs in the ./GraphTutorial/Authentication folder, and add the following code.
namespace GraphTutorial.Authentication { public class TokenValidationResult { // MSAL account ID - used to access the token // cache public string MsalAccountId { get; private set; } // The extracted token - used to build user assertion // for OBO flow public string Token { get; private set; } public TokenValidationResult(string msalAccountId, string token) { MsalAccountId = msalAccountId; Token = token; } } }Create a new file named TokenValidation.cs in the ./GraphTutorial/Authentication folder, and add the following code.
using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using System; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Net.Http.Headers; using System.Security.Claims; using System.Threading.Tasks; namespace GraphTutorial.Authentication { public static class TokenValidation { private static TokenValidationParameters _validationParameters = null; public static async Task<TokenValidationResult> ValidateAuthorizationHeader( Microsoft.Azure.Functions.Worker.Http.HttpRequestData request, string tenantId, string expectedAudience, ILogger log) { // Check for Authorization header if (request.Headers.TryGetValues("authorization", out IEnumerable<string> authValues)) { var authHeader = AuthenticationHeaderValue.Parse(authValues.ToArray().First()); if (authHeader != null && authHeader.Scheme.ToLower() == "bearer" && !string.IsNullOrEmpty(authHeader.Parameter)) { if (_validationParameters == null) { // Load the tenant-specific OpenID config from Azure var configManager = new ConfigurationManager<OpenIdConnectConfiguration>( $"https://login.microsoftonline.com/{tenantId}/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever()); var config = await configManager.GetConfigurationAsync(); _validationParameters = new TokenValidationParameters { // Use signing keys retrieved from Azure IssuerSigningKeys = config.SigningKeys, ValidateAudience = true, // Audience MUST be the app ID for the Web API ValidAudience = expectedAudience, ValidateIssuer = true, // Use the issuer retrieved from Azure ValidIssuer = config.Issuer, ValidateLifetime = true }; } var tokenHandler = new JwtSecurityTokenHandler(); SecurityToken jwtToken; try { // Validate the token var result = tokenHandler.ValidateToken(authHeader.Parameter, _validationParameters, out jwtToken); // If ValidateToken did not throw an exception, token is valid. return new TokenValidationResult(GetMsalAccountId(result), authHeader.Parameter); } catch (Exception exception) { log.LogError(exception, "Error validating bearer token"); } } } return null; } // Helper function to construct an MSAL account ID from the // claims in the token. MSAL uses an ID in the format // oid.tid, where oid is the object ID of the user, and tid is // the tenant ID. private static string GetMsalAccountId(ClaimsPrincipal principal) { var objectId = principal?.FindFirst("oid"); if (objectId == null) { objectId = principal?.FindFirst( "http://schemas.microsoft.com/identity/claims/objectidentifier"); } var tenantId = principal?.FindFirst("tid"); if (tenantId == null) { tenantId = principal?.FindFirst( "http://schemas.microsoft.com/identity/claims/tenantid"); } if (objectId != null && tenantId != null) { return $"{objectId.Value}.{tenantId.Value}"; } return null; } } }
Consider what this code does.
- It ensure there is a bearer token in the
Authorizationheader. - It verifies the signature and issuer from Azure's published OpenID configuration.
- It verifies that the audience (
audclaim) matches the Azure Function's application ID. - It parses the token and generates an MSAL account ID, which will be needed to take advantage of token caching.
Create an on-behalf-of authentication provider
Create a new file in the Authentication directory named OnBehalfOfAuthProvider.cs and add the following code to that file.
using Microsoft.Extensions.Logging; using Microsoft.Graph; using Microsoft.Identity.Client; using System; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; namespace GraphTutorial.Authentication { public class OnBehalfOfAuthProvider : IAuthenticationProvider { private IConfidentialClientApplication _msalClient; private TokenValidationResult _tokenResult; private string[] _scopes; private ILogger _logger; public OnBehalfOfAuthProvider( IConfidentialClientApplication msalClient, TokenValidationResult tokenResult, string[] scopes, ILogger logger) { _scopes = scopes; _logger = logger; _tokenResult = tokenResult; _msalClient = msalClient; } public async Task<string> GetAccessToken() { try { // First attempt to get token from the cache for this user // Check for a matching account in the cache var account = await _msalClient.GetAccountAsync(_tokenResult.MsalAccountId); if (account != null) { // Make a "silent" request for a token. This will // return the cached token if still valid, and will handle // refreshing the token if needed var cacheResult = await _msalClient .AcquireTokenSilent(_scopes, account) .ExecuteAsync(); _logger.LogInformation($"User access token: {cacheResult.AccessToken}"); return cacheResult.AccessToken; } } catch (MsalUiRequiredException) { // This exception indicates that a new token // can only be obtained by invoking the on-behalf-of // flow. "UiRequired" isn't really accurate since the OBO // flow doesn't involve UI. // Catching the exception so code will continue to the // AcquireTokenOnBehalfOf call below. } catch (Exception exception) { _logger.LogError(exception, "Error getting access token via on-behalf-of flow"); return null; } try { _logger.LogInformation("Token not found in cache, attempting OBO flow"); // Use the token sent by the calling client as a // user assertion var userAssertion = new UserAssertion(_tokenResult.Token); // Invoke on-behalf-of flow var result = await _msalClient .AcquireTokenOnBehalfOf(_scopes, userAssertion) .ExecuteAsync(); _logger.LogInformation($"User access token: {result.AccessToken}"); return result.AccessToken; } catch (Exception exception) { _logger.LogError(exception, "Error getting access token from cache"); return null; } } // This is the delegate called by the GraphServiceClient on each // request. public async Task AuthenticateRequestAsync(HttpRequestMessage requestMessage) { // Get the current access token var token = await GetAccessToken(); // Add the token in the Authorization header requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } } }
Take a moment to consider what the code in OnBehalfOfAuthProvider.cs does.
- In the
GetAccessTokenfunction, it first attempts to get a user token from the token cache usingAcquireTokenSilent. If this fails, it uses the bearer token sent by the test app to the Azure Function to generate a user assertion. It then uses that user assertion to get a Graph-compatible token usingAcquireTokenOnBehalfOf. - It implements the
Microsoft.Graph.IAuthenticationProviderinterface, allowing this class to be passed in the constructor of theGraphServiceClientto authenticate outgoing requests.
Implement a Graph client service
In this section you'll implement a service that can be registered for dependency injection. The service will be used to get an authenticated Graph client.
Create a new directory in the GraphTutorial directory named Services.
Create a new file in the Services directory named IGraphClientService.cs and add the following code to that file.
using GraphTutorial.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Graph; namespace GraphTutorial.Services { public interface IGraphClientService { GraphServiceClient GetUserGraphClient( TokenValidationResult validation, string[] scopes, ILogger logger); GraphServiceClient GetAppGraphClient(ILogger logger); } }Create a new file in the Services directory named GraphClientService.cs and add the following code to that file.
using GraphTutorial.Authentication; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Identity.Client; using Microsoft.Graph; namespace GraphTutorial.Services { // Service added via dependency injection // Used to get an authenticated Graph client public class GraphClientService : IGraphClientService { } }Add the following properties to the
GraphClientServiceclass.// Configuration private IConfiguration _config; // Single MSAL client object used for all user-related // requests. Making this a "singleton" here because the sample // uses the default in-memory token cache. private IConfidentialClientApplication _userMsalClient;Add the following functions to the
GraphClientServiceclass.public GraphClientService(IConfiguration config) { _config = config; } public GraphServiceClient GetUserGraphClient(TokenValidationResult validation, string[] scopes, ILogger logger) { // Only create the MSAL client once if (_userMsalClient == null) { _userMsalClient = ConfidentialClientApplicationBuilder .Create(_config["apiFunctionId"]) .WithAuthority(AadAuthorityAudience.AzureAdMyOrg, true) .WithTenantId(_config["tenantId"]) .WithClientSecret(_config["apiFunctionSecret"]) .Build(); } // Create a new OBO auth provider for the specific user var authProvider = new OnBehalfOfAuthProvider(_userMsalClient, validation, scopes, logger); // Return a GraphServiceClient initialized with the auth provider return new GraphServiceClient(authProvider); }Add a placeholder implementation for the
GetAppGraphClientfunction. You will implement that in later sections.public GraphServiceClient GetAppGraphClient() { throw new System.NotImplementedException(); }The
GetUserGraphClientfunction takes the results of token validation and builds an authenticatedGraphServiceClientfor the user.Open ./GraphTutorial/Program.cs and replace its contents with the following.
using System.Reflection; using GraphTutorial.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace GraphTutorial { public class Program { public static void Main() { var host = new HostBuilder() .ConfigureFunctionsWorkerDefaults() .ConfigureAppConfiguration(configuration => { configuration.AddUserSecrets( Assembly.GetExecutingAssembly(), false); }) .ConfigureServices(services => { services.AddSingleton<IGraphClientService, GraphClientService>(); }) .Build(); host.Run(); } } }This code will add user secrets to the configuration, and enable dependency injection in your Azure Functions, exposing the
GraphClientServiceservice.
Implement GetMyNewestMessage function
Open ./GraphTutorial/GetMyNewestMessage.cs and replace its entire contents with the following.
using System.Net; using System.Threading.Tasks; using GraphTutorial.Authentication; using GraphTutorial.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Graph; namespace GraphTutorial { public class GetMyNewestMessage { private IConfiguration _config; private IGraphClientService _clientService; public GetMyNewestMessage(IConfiguration config, IGraphClientService clientService) { _config = config; _clientService = clientService; } [Function("GetMyNewestMessage")] public async Task<HttpResponseData> Run( [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req, FunctionContext executionContext) { var logger = executionContext.GetLogger("GetMyNewestMessage"); // Check configuration if (string.IsNullOrEmpty(_config["apiFunctionId"]) || string.IsNullOrEmpty(_config["apiFunctionSecret"]) || string.IsNullOrEmpty(_config["tenantId"])) { logger.LogError("Invalid app settings configured"); return req.CreateResponse(HttpStatusCode.InternalServerError); } // Validate the bearer token var validationResult = await TokenValidation.ValidateAuthorizationHeader( req, _config["tenantId"], _config["apiFunctionId"], logger); // If token wasn't returned it isn't valid if (validationResult == null) { return req.CreateResponse(HttpStatusCode.Unauthorized); } // Initialize a Graph client for this user var graphClient = _clientService.GetUserGraphClient(validationResult, new[] { "https://graph.microsoft.com/.default" }, logger); // Get the user's newest message in inbox // GET /me/mailfolders/inbox/messages var messagePage = await graphClient.Me .MailFolders .Inbox .Messages .Request() // Limit the fields returned .Select(m => new { m.From, m.ReceivedDateTime, m.Subject }) // Sort by received time, newest on top .OrderBy("receivedDateTime DESC") // Only get back one message .Top(1) .GetAsync(); if (messagePage.CurrentPage.Count > 0) { var response = req.CreateResponse(HttpStatusCode.OK); // Return the message in the response await response.WriteAsJsonAsync<Message>(messagePage.CurrentPage[0]); return response; } return req.CreateResponse(HttpStatusCode.NoContent); } } }
Review the code in GetMyNewestMessage.cs
Take a moment to consider what the code in GetMyNewestMessage.cs does.
- In the constructor, it saves the
IConfigurationandIGraphClientServiceobjects passed in via dependency injection. - In the
Runfunction, it does the following:- Validates the required configuration values are present in the
IConfigurationobject. - Validates the bearer token and returns a
401status code if the token is invalid. - Gets a Graph client from the
GraphClientServicefor the user that made this request. - Uses the Microsoft Graph SDK to get the newest message from the user's inbox and returns it as a JSON body in the response.
- Validates the required configuration values are present in the
Call the Azure Function from the test app
Open auth.js and add the following function to get an access token.
async function getToken() { let account = sessionStorage.getItem('msal-userName'); if (!account){ throw new Error( 'User account missing from session. Please sign out and sign in again.'); } try { // First, attempt to get the token silently const silentRequest = { scopes: msalRequest.scopes, account: msalClient.getAccountByUsername(account) }; const silentResult = await msalClient.acquireTokenSilent(silentRequest); return silentResult.accessToken; } catch (silentError) { // If silent requests fails with InteractionRequiredAuthError, // attempt to get the token interactively if (silentError instanceof msal.InteractionRequiredAuthError) { const interactiveResult = await msalClient.acquireTokenPopup(msalRequest); return interactiveResult.accessToken; } else { throw silentError; } } }Consider what this code does.
- It first attempts to get an access token silently, without user interaction. Since the user should already be signed in, MSAL should have tokens for the user in its cache.
- If that fails with an error that indicates the user needs to interact, it attempts to get a token interactively.
Tip
You can parse the access token at https://jwt.ms and confirm that the
audclaim is the app ID for the Azure Function, and that thescpclaim contains the Azure Function's permission scope, not Microsoft Graph.Create a new file in the TestClient directory named azurefunctions.js and add the following code.
async function getLatestMessage() { const token = await getToken(); if (!token) { updatePage(Views.error, { message: 'Could not retrieve token for user' }); return; } try { const response = await fetch('http://localhost:7071/api/GetMyNewestMessage', { headers: { Authorization: `Bearer ${token}` } }); if (response.status === 200) { const message = await response.json(); updatePage(Views.message, message); } else if (response.status === 204) { updatePage(Views.message, null); } else { updatePage(Views.error, { message: `Error getting message: ${response.status} ${response.statusText}` }); } } catch (error) { updatePage(Views.error, { message: 'Error getting message', debug: error }); } }Change the current directory in your CLI to the ./GraphTutorial directory and run the following command to start the Azure Function locally.
func startIf not already serving the SPA, open a second CLI window and change the current directory to the ./TestClient directory. Run the following command to run the test application.
dotnet serve -h "Cache-Control: no-cache, no-store, must-revalidate"Open your browser and navigate to
http://localhost:8080. Sign in and select the Latest Message navigation item. The app displays information about the newest message in the user's inbox.
Implement the webhook with client credentials authentication
In this exercise you will finish implementing the Azure Functions SetSubscription and Notify, and update the test application to subscribe and unsubscribe to changes in a user's inbox.
- The
SetSubscriptionfunction will act as an API, allowing the test app to create or delete a subscription to changes in a user's inbox. - The
Notifyfunction will act as the webhook that receives change notifications generated by the subscription.
Both functions will use the client credentials grant flow to get an app-only token to call Microsoft Graph. Because an administrator granted admin consent to the required permission scopes, no user interaction will be required to get the token.
Add client credentials authentication to the Azure Functions project
In this section you'll implement the client credentials flow in the Azure Functions project to get an access token compatible with Microsoft Graph.
Open your CLI in the directory that contains GraphTutorial.csproj.
Add your webhook application ID and secret to the secret store using the following commands. Replace
YOUR_WEBHOOK_APP_ID_HEREwith the application ID for the Graph Azure Function Webhook. ReplaceYOUR_WEBHOOK_APP_SECRET_HEREwith the application secret you created in the Azure portal for the Graph Azure Function Webhook.dotnet user-secrets set webHookId "YOUR_WEBHOOK_APP_ID_HERE" dotnet user-secrets set webHookSecret "YOUR_WEBHOOK_APP_SECRET_HERE"
Create a client credentials authentication provider
Create a new file in the ./GraphTutorial/Authentication directory named ClientCredentialsAuthProvider.cs and add the following code.
using Microsoft.Extensions.Logging; using Microsoft.Graph; using Microsoft.Identity.Client; using System; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; namespace GraphTutorial.Authentication { public class ClientCredentialsAuthProvider : IAuthenticationProvider { private IConfidentialClientApplication _msalClient; private string[] _scopes; private ILogger _logger; public ClientCredentialsAuthProvider( string appId, string clientSecret, string tenantId, string[] scopes, ILogger logger) { _scopes = scopes; _logger = logger; _msalClient = ConfidentialClientApplicationBuilder .Create(appId) .WithAuthority(AadAuthorityAudience.AzureAdMyOrg, true) .WithTenantId(tenantId) .WithClientSecret(clientSecret) .Build(); } public async Task<string> GetAccessToken() { try { // Invoke client credentials flow // NOTE: This will return a cached token if a valid one // exists var result = await _msalClient .AcquireTokenForClient(_scopes) .ExecuteAsync(); _logger.LogInformation($"App-only access token: {result.AccessToken}"); return result.AccessToken; } catch (Exception exception) { _logger.LogError(exception, "Error getting access token via client credentials flow"); return null; } } // This is the delegate called by the GraphServiceClient on each // request. public async Task AuthenticateRequestAsync(HttpRequestMessage requestMessage) { // Get the current access token var token = await GetAccessToken(); // Add the token in the Authorization header requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } } }
Take a moment to consider what the code in ClientCredentialsAuthProvider.cs does.
- In the constructor, it initializes a ConfidentialClientApplication from the
Microsoft.Identity.Clientpackage. It uses theWithAuthority(AadAuthorityAudience.AzureAdMyOrg, true)and.WithTenantId(tenantId)functions to restrict the login audience to only the specified Microsoft 365 organization. - In the
GetAccessTokenfunction, it callsAcquireTokenForClientto get a token for the application. The client credentials token flow is always non-interactive. - It implements the
Microsoft.Graph.IAuthenticationProviderinterface, allowing this class to be passed in the constructor of theGraphServiceClientto authenticate outgoing requests.
Update GraphClientService
Open GraphClientService.cs and add the following property to the class.
private GraphServiceClient _appGraphClient;Replace the existing
GetAppGraphClientfunction with the following.public GraphServiceClient GetAppGraphClient(ILogger logger) { if (_appGraphClient == null) { // Create a client credentials auth provider var authProvider = new ClientCredentialsAuthProvider( _config["webHookId"], _config["webHookSecret"], _config["tenantId"], // The https://graph.microsoft.com/.default scope // is required for client credentials. It requests // all of the permissions that are explicitly set on // the app registration new[] { "https://graph.microsoft.com/.default" }, logger); _appGraphClient = new GraphServiceClient(authProvider); } return _appGraphClient; }
Implement Notify function
In this section you'll implement the Notify function, which will be used as the notification URL for change notifications.
Create a new directory in the GraphTutorials directory named Models.
Create a new file in the Models directory named ResourceData.cs and add the following code.
namespace GraphTutorial.Models { // Class to represent the resourceData object // inside a change notification public class ResourceData { public string Id { get;set; } } }Create a new file in the Models directory named ChangeNotificationPayload.cs and add the following code.
using System; namespace GraphTutorial.Models { // Represents a change notification payload // https://docs.microsoft.com/graph/api/resources/changenotification?view=graph-rest-1.0 public class ChangeNotificationPayload { public string ChangeType { get;set; } public string ClientState { get;set; } public string Resource { get;set; } public ResourceData ResourceData { get;set; } public DateTime SubscriptionExpirationDateTime { get;set; } public string SubscriptionId { get;set; } public string TenantId { get;set; } } }Create a new file in the Models directory named NotificationList.cs and add the following code.
namespace GraphTutorial.Models { // Class representing an array of notifications // in a notification payload public class NotificationList { public ChangeNotificationPayload[] Value { get;set; } } }Open ./GraphTutorial/Notify.cs and replace its entire contents with the following.
using System.IO; using System.Net; using System.Text.Json; using System.Threading.Tasks; using GraphTutorial.Models; using GraphTutorial.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Graph; namespace GraphTutorial { public class Notify { public static readonly string ClientState = "GraphTutorialState"; private IConfiguration _config; private IGraphClientService _clientService; public Notify(IConfiguration config, IGraphClientService clientService) { _config = config; _clientService = clientService; } [Function("Notify")] public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, FunctionContext executionContext) { var logger = executionContext.GetLogger("Notify"); // Check configuration if (string.IsNullOrEmpty(_config["webHookId"]) || string.IsNullOrEmpty(_config["webHookSecret"]) || string.IsNullOrEmpty(_config["tenantId"])) { logger.LogError("Invalid app settings configured"); return req.CreateResponse(HttpStatusCode.InternalServerError); } // Is this a validation request? // https://docs.microsoft.com/graph/webhooks#notification-endpoint-validation if (executionContext.BindingContext.BindingData .TryGetValue("validationToken", out object validationToken)) { // Because validationToken is a string, OkObjectResult // will return a text/plain response body, which is // required for validation var response = req.CreateResponse(HttpStatusCode.OK); response.Headers.Add("Content-Type", "text/plain; charset=utf-8"); response.WriteString(validationToken.ToString()); return response; } // Not a validation request, process the body var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); logger.LogInformation($"Change notification payload: {requestBody}"); var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; // Deserialize the JSON payload into a list of ChangeNotificationPayload // objects var notifications = JsonSerializer.Deserialize<NotificationList>(requestBody, jsonOptions); foreach (var notification in notifications.Value) { if (notification.ClientState == ClientState) { // Process each notification await ProcessNotification(notification, logger); } else { logger.LogInformation($"Notification received with unexpected client state: {notification.ClientState}"); } } // Return 202 per docs return req.CreateResponse(HttpStatusCode.Accepted); } private async Task ProcessNotification(ChangeNotificationPayload notification, ILogger log) { var graphClient = _clientService.GetAppGraphClient(log); // The resource field in the notification has the URL to the // message, including the user ID and message ID. Since we // have the URL, use a MessageRequestBuilder instead of the fluent // API var msgRequestBuilder = new MessageRequestBuilder( $"https://graph.microsoft.com/v1.0/{notification.Resource}", graphClient); var message = await msgRequestBuilder.Request() .Select(m => new { m.Subject }) .GetAsync(); log.LogInformation($"The following message was {notification.ChangeType}:"); log.LogInformation($"Subject: {message.Subject}, ID: {message.Id}"); } } }
Take a moment to consider what the code in Notify.cs does.
- The
Runfunction checks for the presence of avalidationTokenquery parameter. If that parameter is present, it processes the request as a validation request, and responds accordingly. - If the request is not a validation request, the JSON payload is deserialized into a
ChangeNotificationCollection. - Each notification in the list is checked for the expected client state value, and is processed.
- The message that triggered the notification is retrieved with Microsoft Graph.
Implement SetSubscription function
In this section, you'll implement the SetSubscription function. This function will act as an API that is called by the test application to create or delete a subscription on a user's inbox.
Create a new file in the Models directory named SetSubscriptionPayload.cs and add the following code.
namespace GraphTutorial.Models { // Class to represent the payload sent to the // SetSubscription function public class SetSubscriptionPayload { // "subscribe" or "unsubscribe" public string RequestType { get;set; } // If unsubscribing, the subscription to delete public string SubscriptionId { get;set; } // If subscribing, the user ID to subscribe to // Can be object ID of user, or userPrincipalName public string UserId { get;set; } } }Open ./GraphTutorial/SetSubscription.cs and replace its entire contents with the following.
using System; using System.IO; using System.Net; using System.Text.Json; using System.Threading.Tasks; using GraphTutorial.Authentication; using GraphTutorial.Models; using GraphTutorial.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Graph; namespace GraphTutorial { public class SetSubscription { private IConfiguration _config; private IGraphClientService _clientService; public SetSubscription(IConfiguration config, IGraphClientService clientService) { _config = config; _clientService = clientService; } [Function("SetSubscription")] public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req, FunctionContext executionContext) { var logger = executionContext.GetLogger("SetSubscription"); // Check configuration if (string.IsNullOrEmpty(_config["webHookId"]) || string.IsNullOrEmpty(_config["webHookSecret"]) || string.IsNullOrEmpty(_config["tenantId"]) || string.IsNullOrEmpty(_config["apiFunctionId"])) { logger.LogError("Invalid app settings configured"); return req.CreateResponse(HttpStatusCode.InternalServerError); } var notificationHost = _config["ngrokUrl"]; if (string.IsNullOrEmpty(notificationHost)) { notificationHost = req.Url.Host; } // Validate the bearer token var validationResult = await TokenValidation.ValidateAuthorizationHeader( req, _config["tenantId"], _config["apiFunctionId"], logger); // If token wasn't returned it isn't valid if (validationResult == null) { return req.CreateResponse(HttpStatusCode.Unauthorized); } var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; // Deserialize the JSON payload into a SetSubscriptionPayload object var payload = JsonSerializer.Deserialize<SetSubscriptionPayload>(requestBody, jsonOptions); if (payload == null) { var response = req.CreateResponse(HttpStatusCode.BadRequest); response.WriteString("Invalid request payload"); return response; } // Initialize Graph client var graphClient = _clientService.GetAppGraphClient(logger); if (payload.RequestType.ToLower() == "subscribe") { if (string.IsNullOrEmpty(payload.UserId)) { var response = req.CreateResponse(HttpStatusCode.BadRequest); response.WriteString("Required fields in payload missing"); return response; } // Create a new subscription object var subscription = new Subscription { ChangeType = "created,updated", NotificationUrl = $"{notificationHost}/api/Notify", Resource = $"/users/{payload.UserId}/mailfolders/inbox/messages", ExpirationDateTime = DateTimeOffset.UtcNow.AddDays(2), ClientState = Notify.ClientState }; // POST /subscriptions var createdSubscription = await graphClient.Subscriptions .Request() .AddAsync(subscription); var okResponse = req.CreateResponse(HttpStatusCode.OK); await okResponse.WriteAsJsonAsync<Subscription>(createdSubscription); return okResponse; } else { if (string.IsNullOrEmpty(payload.SubscriptionId)) { var response = req.CreateResponse(HttpStatusCode.BadRequest); response.WriteString("Subscription ID missing in payload"); return response; } // DELETE /subscriptions/subscriptionId await graphClient.Subscriptions[payload.SubscriptionId] .Request() .DeleteAsync(); return req.CreateResponse(HttpStatusCode.Accepted); } } } }
Take a moment to consider what the code in SetSubscription.cs does.
- The
Runfunction reads the JSON payload sent in the POST request to determine the request type (subscribe or unsubscribe), the user ID to subscribe for, and the subscription ID to unsubscribe. - If the request is a subscribe request, it uses the Microsoft Graph SDK to create a new subscription in the specified user's inbox. The subscription will notify when messages are created or updated. The new subscription is returned in the JSON payload of the response.
- If the request is an unsubscribe request, it uses the Microsoft Graph SDK to delete the specified subscription.
Call SetSubscription from the test app
In this section, you'll implement functions to create and delete subscriptions in the test app.
Open ./TestClient/azurefunctions.js and add the following function.
async function createSubscription() { // Get the user to subscribe for const userId = document.getElementById('subscribe-user').value; if (!userId) { updatePage(Views.error, { message: 'Please provide a user ID or userPrincipalName' }); return; } const token = await getToken(); if (!token) { updatePage(Views.error, { message: 'Could not retrieve token for user' }); return; } // Build the JSON payload for the subscribe request const payload = { requestType: 'subscribe', userId: userId }; const response = await fetch('http://localhost:7071/api/SetSubscription', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify(payload) }); if (response.ok) { // Get the new subscription from the response const subscription = await response.json(); // Add the new subscription to the array of subscriptions // in the session let existingSubscriptions = JSON.parse(sessionStorage.getItem('graph-subscriptions')) || []; existingSubscriptions.push({ userId: userId, subscriptionId: subscription.id }); sessionStorage.setItem('graph-subscriptions', JSON.stringify(existingSubscriptions)); // Refresh the subscriptions page to display the new // subscription updatePage(Views.subscriptions); return; } updatePage(Views.error, { message: `Call to SetSubscription returned ${response.status}`, debug: response.statusText }); }This code calls the
SetSubscriptionAzure Function to subscribe and adds the new subscription to the array of subscriptions in the session.Add the following function to azurefunctions.js.
async function deleteSubscription(subscriptionId) { const token = await getToken(); if (!token) { updatePage(Views.error, { message: 'Could not retrieve token for user' }); return; } // Build the JSON payload for the unsubscribe request const payload = { requestType: 'unsubscribe', subscriptionId: subscriptionId }; const response = await fetch('http://localhost:7071/api/SetSubscription', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify(payload) }); if (response.ok) { // Remove the subscription from the array let existingSubscriptions = JSON.parse(sessionStorage.getItem('graph-subscriptions')) || []; const subscriptionIndex = existingSubscriptions.findIndex((item) => { return item.subscriptionId === subscriptionId; }); existingSubscriptions.splice(subscriptionIndex, 1); sessionStorage.setItem('graph-subscriptions', JSON.stringify(existingSubscriptions)); // Refresh the subscriptions page updatePage(Views.subscriptions); return; } updatePage(Views.error, { message: `Call to SetSubscription returned ${response.status}`, debug: response.statusText }); }This code calls the
SetSubscriptionAzure Function to unsubscribe and removes the subscription from the array of subscriptions in the session.If you do not have ngrok running, run ngrok (
ngrok http 7071) and copy the HTTPS forwarding URL.Add the ngrok URL to the user secrets store by running the following command.
dotnet user-secrets set ngrokUrl "YOUR_NGROK_URL_HERE"Important
If you restart ngrok, you will need to repeat this command to update your ngrok URL.
Change the current directory in your CLI to the ./GraphTutorial directory and run the following command to start the Azure Function locally.
func startRefresh the SPA and select the Subscriptions nav item. Enter a user ID for a user in your Microsoft 365 organization that has an Exchange Online mailbox. This can either be the user's
id(from Microsoft Graph) or the user'suserPrincipalName. Click Subscribe.The page refreshes showing the new subscription in the table.
Send an email to the user. After a brief time, the
Notifyfunction should be called. You can verify this in the ngrok web interface (http://localhost:4040) or in the debug output of the Azure Function project.... [7/8/2020 7:33:57 PM] The following message was created: [7/8/2020 7:33:57 PM] Subject: Hi Megan!, ID: AAMkAGUyN2I4N2RlLTEzMTAtNDBmYy1hODdlLTY2NTQwODE2MGEwZgBGAAAAAAA2J9QH-DvMRK3pBt_8rA6nBwCuPIFjbMEkToHcVnQirM5qAAAAAAEMAACuPIFjbMEkToHcVnQirM5qAACHmpAsAAA= [7/8/2020 7:33:57 PM] Executed 'Notify' (Succeeded, Id=9c40af0b-e082-4418-aa3a-aee624f30e7a) ...In the test app, click Delete in the table row for the subscription. The page refreshes and the subscription is no longer in the table.
Prepare to publish to Azure
In this exercise you'll learn about what changes are needed to the sample Azure Function to prepare for publishing to an Azure Functions app.
Update code
Configuration is read from the user secret store, which only applies to your development machine. Before you publish to Azure, you'll need to change where you store your configuration, and update the code in Program.cs accordingly.
Application secrets should be stored in secure storage, such as Azure Key Vault.
Update CORS setting for Azure Function
In this sample we configured CORS in local.settings.json to allow the test application to call the function. You'll need to configure your published function to allow any SPA apps that will call it.
Update app registrations
The knownClientApplications property in the manifest for the Graph Azure Function app registration will need to be updated with the application IDs of any apps that will be calling the Azure Function.
Recreate existing subscriptions
Any subscriptions created using the webhook URL on your local machine or ngrok should be recreated using the production URL of the Notify Azure Function.
Congratulations!
You've completed the Azure Functions Microsoft Graph tutorial. Now that you have a working app that calls Microsoft Graph, you can experiment and add new features. Visit the Overview of Microsoft Graph to see all of the data you can access with Microsoft Graph.
Feedback
Please provide any feedback on this tutorial in the GitHub repository.
Have an issue with this section? If so, please give us some feedback so we can improve this section.