Build Microsoft Teams apps with Microsoft Graph
This tutorial teaches you how to build a Microsoft Teams app using ASP.NET Core and 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 installed on your development machine.
You should also have a Microsoft work or school account in a Microsoft 365 tenant that has enabled custom Teams app sideloading. If you don't have a Microsoft work or school account, or your organization has not enabled custom Teams app sideloading, you can sign up for the Microsoft 365 Developer Program to get a free Office 365 developer subscription.
Note
This tutorial was written with .NET SDK version 5.0.302. The steps in this guide may work with other versions, but that has not been tested.
Feedback
Please provide any feedback on this tutorial in the GitHub repository.
Create an ASP.NET Core MVC web app
Microsoft Teams tab applications have multiple options to authenticate the user and call Microsoft Graph. In this exercise, you'll implement a tab that does single sign-on to get an auth token on the client, then uses the on-behalf-of flow on the server to exchange that token to get access to Microsoft Graph.
For other alternatives, see the following.
- Build a Microsoft Teams tab with the Microsoft Graph Toolkit. This sample is completely client-side, and uses the Microsoft Graph Toolkit to handle authentication and making calls to Microsoft Graph.
- Microsoft Teams Authentication Sample. This sample contains multiple examples covering different authentication scenarios.
Create the project
Start by creating an ASP.NET Core web app.
Open your command-line interface (CLI) in a directory where you want to create the project. Run the following command.
dotnet new webapp -o GraphTutorial
Once the project is created, verify that it works by changing the current directory to the GraphTutorial directory and running the following command in your CLI.
dotnet run
Open your browser and browse to
https://localhost:5001
. If everything is working, you should see a default ASP.NET Core page.
Important
If you receive a warning that the certificate for localhost is un-trusted you can use the .NET Core CLI to install and trust the development certificate. See Enforce HTTPS in ASP.NET Core for instructions for specific operating systems.
Add NuGet packages
Before moving on, install some additional NuGet packages that you will use later.
- Microsoft.Identity.Web for authenticating and requesting access tokens.
- Microsoft.Identity.Web.MicrosoftGraph for adding Microsoft Graph support configured with Microsoft.Identity.Web.
- Microsoft.Graph to update the version of this package installed by Microsoft.Identity.Web.MicrosoftGraph.
- TimeZoneConverter for translating Windows time zone identifiers to IANA identifiers.
Run the following commands in your CLI to install the dependencies.
dotnet add package Microsoft.Identity.Web --version 1.15.2 dotnet add package Microsoft.Identity.Web.MicrosoftGraph --version 1.15.2 dotnet add package Microsoft.Graph --version 4.1.0 dotnet add package TimeZoneConverter
Design the app
In this section you will create the basic UI structure of the application.
Tip
You can use any text editor to edit the source files for this tutorial. However, Visual Studio Code provides additional features, such as debugging and Intellisense.
Open ./Pages/Shared/_Layout.cshtml and replace its entire contents with the following code to update the global layout of the app.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>@ViewData["Title"] - GraphTutorial</title> <link rel="stylesheet" href="https://static2.sharepointonline.com/files/fabric/office-ui-fabric-core/11.0.0/css/fabric.min.css" /> <link rel="stylesheet" href="~/css/site.css" /> </head> <body class="ms-Fabric"> <div class="container"> <main role="main"> @RenderBody() </main> </div> <script src="~/lib/jquery/dist/jquery.min.js"></script> <script src="https://statics.teams.cdn.office.net/sdk/v1.10.0/js/MicrosoftTeams.min.js"></script> <script src="~/js/site.js" asp-append-version="true"></script> @RenderSection("Scripts", required: false) </body> </html>
This replaces Bootstrap with Fluent UI, adds the Microsoft Teams SDK, and simplifies the layout.
Open ./wwwroot/js/site.js and add the following code.
(function () { // Support Teams themes microsoftTeams.initialize(); // On load, match the current theme microsoftTeams.getContext((context) => { if(context.theme !== 'default') { // For Dark and High contrast, set text to white document.body.style.color = '#fff'; document.body.style.setProperty('--border-style', 'solid'); } }); // Register event listener for theme change microsoftTeams.registerOnThemeChangeHandler((theme)=> { if(theme !== 'default') { document.body.style.color = '#fff'; document.body.style.setProperty('--border-style', 'solid'); } else { // For default theme, remove inline style document.body.style.color = ''; document.body.style.setProperty('--border-style', 'none'); } }); })();
This adds a simple theme change handler to change the default text color for dark and high contrast themes.
Open ./wwwroot/css/site.css and replace its contents with the following.
:root { --border-style: none; } .tab-title { margin-bottom: .5em; } .event-card { margin: .5em; padding: 1em; border-style: var(--border-style); border-width: 1px; border-color: #fff; } .event-card div { margin-bottom: .25em; } .event-card .ms-Icon { margin-right: 10px; float: left; position: relative; top: 3px; } .event-card .ms-Icon--MapPin { top: 2px; } .form-container { max-width: 720px; } .form-label { display: block; margin-bottom: .25em; } .form-input { width: 100%; margin-bottom: .25em; padding: .5em; box-sizing: border-box; } .form-button { padding: .5em; } .result-panel { display: none; padding: 1em; margin: 1em; } .error-msg { color: red; } .success-msg { color: green; }
Open ./Pages/Index.cshtml and replace its contents with the following code.
@page @model IndexModel @{ ViewData["Title"] = "Home page"; } <div id="tab-container"> <h1 class="ms-fontSize-24 ms-fontWeight-semibold">Loading...</h1> </div> @section Scripts { <script> </script> }
Open ./Startup.cs and remove the
app.UseHttpsRedirection();
line in theConfigure
method. This is necessary for ngrok tunneling to work.
Run ngrok
Microsoft Teams does not support local hosting for apps. The server hosting your app must be available from the cloud using HTTPS endpoints. For debugging locally, you can use ngrok to create a public URL for your locally-hosted project.
Open your CLI and run the following command to start ngrok.
ngrok http 5000
Once ngrok starts, copy the HTTPS Forwarding URL. It should look like
https://50153897dd4d.ngrok.io
. You'll need this value in later steps.
Important
If you are using the free version of ngrok, the forwarding URL changes every time you restart ngrok. It's recommended that you leave ngrok running until you complete this tutorial to keep the same URL. If you have to restart ngrok, you'll need to update your URL everywhere that it is used and reinstall the app in Microsoft Teams.
Register the app in the portal
In this exercise, you will create a new Azure AD web application registration using the Azure Active Directory admin center.
Open a browser and navigate to the Azure Active Directory admin center. Login using a personal account (aka: Microsoft Account) or Work or School Account.
Select Azure Active Directory in the left-hand navigation, then select App registrations under Manage.
Select New registration. On the Register an application page, set the values as follows, where
YOUR_NGROK_URL
is the ngrok forwarding URL you copied in the previous section.- Set Name to
Teams Graph Tutorial
. - Set Supported account types to Accounts in any organizational directory and personal Microsoft accounts.
- Under Redirect URI, set the first drop-down to
Web
and set the value toYOUR_NGROK_URL/authcomplete
.
- Set Name to
Select Register. On the Teams Graph Tutorial page, copy the value of the Application (client) ID and save it, you will need it in the next step.
Select Authentication under Manage. Locate the Implicit grant section and enable Access tokens and ID tokens. Select Save.
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, then select Add a permission.
Select Microsoft Graph, then Delegated permissions.
Select the following permissions, then select Add permissions.
- Calendars.ReadWrite - this will allow the app to read and write to the user's calendar.
- MailboxSettings.Read - this will allow the app to get the user's time zone, date format, and time format from their mailbox settings.
Configure Teams single sign-on
In this section you'll update the app registration to support single sign-on in Teams.
Select Expose an API. Select the Set link next to Application ID URI. Insert your ngrok forwarding URL domain name (with a forward slash "/" appended to the end) between the double forward slashes and the GUID. The entire ID should look similar to:
api://50153897dd4d.ngrok.io/ae7d8088-3422-4c8c-a351-6ded0f21d615
.In the Scopes defined by this API section, select Add a scope. Fill in the fields as follows and select Add scope.
- Scope name:
access_as_user
- Who can consent?: Admins and users
- Admin consent display name:
Access the app as the user
- Admin consent description:
Allows Teams to call the app's web APIs as the current user.
- User consent display name:
Access the app as you
- User consent description:
Allows Teams to call the app's web APIs as you.
- State: Enabled
- Scope name:
In the Authorized client applications section, select Add a client application. Enter a client ID from the following list, enable the scope under Authorized scopes, and select Add application. Repeat this process for each of the client IDs in the list.
1fec8e78-bce4-4aaf-ab1b-5451cc387264
(Teams mobile/desktop application)5e3ce6c0-2b1f-4285-8d4b-75ee78787346
(Teams web application)
Create app manifest
The app manifest describes how the app integrates with Microsoft Teams and is required to install apps. In this section you'll use App Studio in the Microsoft Teams client to generate a manifest.
If you do not already have App Studio installed in Teams, install it now.
Launch App Studio in Microsoft Teams and select the Manifest editor.
Select Create a new app.
On the App details page, fill in the required fields.
Note
You can use the default icons in the Branding section or upload your own.
On the left-hand menu, select Tabs under Capabilities.
Select Add under Add a personal tab.
Fill in the fields as follows, where
YOUR_NGROK_URL
is the forwarding URL you copied in the previous section. Select Save when done.- Name:
Create event
- Entity ID:
createEventTab
- Content URL:
YOUR_NGROK_URL/newevent
- Name:
Select Add under Add a personal tab.
Fill in the fields as follows, where
YOUR_NGROK_URL
is the forwarding URL you copied in the previous section. Select Save when done.- Name:
Graph calendar
- Entity ID:
calendarTab
- Content URL:
YOUR_NGROK_URL
- Name:
On the left-hand menu, select Domains and permissions under Finish.
Set the AAD App ID to the application ID from your app registration.
Set the Single-Sign-On field to the application ID URI from your app registration.
On the left-hand menu, select Test and distribute under Finish. Select Download.
Create a new directory in the root of the project named Manifest. Extract the contents of the downloaded ZIP file to this directory.
Add Azure AD authentication
In this exercise you will extend the application from the previous exercise to support single sign-on authentication with Azure AD. This is required to obtain the necessary OAuth access token to call the Microsoft Graph API. In this step you will configure the Microsoft.Identity.Web library.
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.
Open ./appsettings.json and replace its contents with the following.
{ "AzureAd": { "Instance": "https://login.microsoftonline.com/", "TenantId": "common" }, "Graph": { "Scopes": "https://graph.microsoft.com/.default" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } }, "AllowedHosts": "*" }
Open your CLI in the directory where GraphTutorial.csproj is located, and run the following commands, substituting
YOUR_APP_ID
with your application ID from the Azure portal, andYOUR_APP_SECRET
with your application secret.dotnet user-secrets init dotnet user-secrets set "AzureAd:ClientId" "YOUR_APP_ID" dotnet user-secrets set "AzureAd:ClientSecret" "YOUR_APP_SECRET"
Implement sign-in
First, implement single sign-on in the app's JavaScript code. You will use the Microsoft Teams JavaScript SDK to get an access token which allows the JavaScript code running in the Teams client to make AJAX calls to Web API you will implement later.
Open ./Pages/Index.cshtml and add the following code inside the
<script>
tag.(function () { if (microsoftTeams) { microsoftTeams.initialize(); microsoftTeams.authentication.getAuthToken({ successCallback: (token) => { // TEMPORARY: Display the access token for debugging $('#tab-container').empty(); $('<code/>', { text: token, style: 'word-break: break-all;' }).appendTo('#tab-container'); }, failureCallback: (error) => { renderError(error); } }); } })(); function renderError(error) { $('#tab-container').empty(); $('<h1/>', { text: 'Error' }).appendTo('#tab-container'); $('<code/>', { text: JSON.stringify(error, Object.getOwnPropertyNames(error)), style: 'word-break: break-all;' }).appendTo('#tab-container'); }
This calls the
microsoftTeams.authentication.getAuthToken
to silently authenticate as the user that is signed in to Teams. There is typically not any UI prompts involved, unless the user has to consent. The code then displays the token in the tab.Save your changes and start your application by running the following command in your CLI.
dotnet run
Important
If you have restarted ngrok and your ngrok URL has changed, be sure to update the ngrok value in the following place before you test.
- The redirect URI in your app registration
- The application ID URI in your app registration
contentUrl
in manifest.jsonvalidDomains
in manifest.jsonresource
in manifest.json
Create a ZIP file with manifest.json, color.png, and outline.png.
In Microsoft Teams, select Apps in the left-hand bar, select Upload a custom app, then select Upload for me or my teams.
Browse to the ZIP file you created previously and select Open.
Review the application information and select Add.
The application opens in Teams and displays an access token.
If you copy the token, you can paste it into jwt.ms. Verify that the audience (the aud
claim) is your application ID, and the only scope (the scp
claim) is the access_as_user
API scope you created. That means that this token does not grant direct access to Microsoft Graph! Instead, the Web API you will implement soon will need to exchange this token using the on-behalf-of flow to get a token that will work with Microsoft Graph calls.
Configure authentication in the ASP.NET Core app
Start by adding the Microsoft Identity platform services to the application.
Open the ./Startup.cs file and add the following
using
statement to the top of the file.using Microsoft.Identity.Web;
Add the following line just before the
app.UseAuthorization();
line in theConfigure
function.app.UseAuthentication();
Add the following line just after the
endpoints.MapRazorPages();
line in theConfigure
function.endpoints.MapControllers();
Replace the existing
ConfigureServices
function with the following.public void ConfigureServices(IServiceCollection services) { // Use Web API authentication (default JWT bearer token scheme) services.AddMicrosoftIdentityWebApiAuthentication(Configuration) // Enable token acquisition via on-behalf-of flow .EnableTokenAcquisitionToCallDownstreamApi() // Specify that the down-stream API is Graph .AddMicrosoftGraph(Configuration.GetSection("Graph")) // Use in-memory token cache // See https://github.com/AzureAD/microsoft-identity-web/wiki/token-cache-serialization .AddInMemoryTokenCaches(); services.AddRazorPages(); services.AddControllers(); }
This code configures the application to allow calls to Web APIs to be authenticated based on the JWT bearer token in the
Authorization
header. It also adds the token acquisition services that can exchange that token via the on-behalf-of flow.
Create the Web API controller
Create a new directory in the root of the project named Controllers.
Create a new file in the ./Controllers directory named CalendarController.cs and add the following code.
using System; using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Identity.Web; using Microsoft.Identity.Web.Resource; using Microsoft.Graph; using TimeZoneConverter; namespace GraphTutorial.Controllers { [ApiController] [Route("[controller]")] [Authorize] public class CalendarController : ControllerBase { private static readonly string[] apiScopes = new[] { "access_as_user" }; private readonly GraphServiceClient _graphClient; private readonly ITokenAcquisition _tokenAcquisition; private readonly ILogger<CalendarController> _logger; public CalendarController(ITokenAcquisition tokenAcquisition, GraphServiceClient graphClient, ILogger<CalendarController> logger) { _tokenAcquisition = tokenAcquisition; _graphClient = graphClient; _logger = logger; } [HttpGet] public async Task<ActionResult<string>> Get() { // This verifies that the access_as_user scope is // present in the bearer token, throws if not HttpContext.VerifyUserHasAnyAcceptedScope(apiScopes); // To verify that the identity libraries have authenticated // based on the token, log the user's name _logger.LogInformation($"Authenticated user: {User.GetDisplayName()}"); try { // TEMPORARY // Get a Graph token via OBO flow var token = await _tokenAcquisition .GetAccessTokenForUserAsync(new[]{ "User.Read", "MailboxSettings.Read", "Calendars.ReadWrite" }); // Log the token _logger.LogInformation($"Access token for Graph: {token}"); return Ok("{ \"status\": \"OK\" }"); } catch (MicrosoftIdentityWebChallengeUserException ex) { _logger.LogError(ex, "Consent required"); // This exception indicates consent is required. // Return a 403 with "consent_required" in the body // to signal to the tab it needs to prompt for consent return new ContentResult { StatusCode = (int)HttpStatusCode.Forbidden, ContentType = "text/plain", Content = "consent_required" }; } catch (Exception ex) { _logger.LogError(ex, "Error occurred"); throw; } } } }
This implements a Web API (
GET /calendar
) that can be called from the Teams tab. For now it simply tries to exchange the bearer token for a Graph token. The first time a user loads the tab, this will fail because they have not yet consented to allow the app access to Microsoft Graph on their behalf.Open ./Pages/Index.cshtml and replace the
successCallback
function with the following.successCallback: (token) => { // TEMPORARY: Call the Web API fetch('/calendar', { headers: { 'Authorization': `Bearer ${token}` } }).then(response => { response.text() .then(body => { $('#tab-container').empty(); $('<code/>', { text: body }).appendTo('#tab-container'); }); }).catch(error => { console.error(error); renderError(error); }); }
This will call the Web API and display the response.
Save your changes and restart the app. Refresh the tab in Microsoft Teams. The page should display
consent_required
.Review the log output in your CLI. Notice two things.
- An entry like
Authenticated user: MeganB@contoso.com
. The Web API has authenticated the user based on the token sent with the API request. - An entry like
AADSTS65001: The user or administrator has not consented to use the application with ID...
. This is expected, since the user has not yet been prompted to consent for the requested Microsoft Graph permission scopes.
- An entry like
Implement consent prompt
Because the Web API cannot prompt the user, the Teams tab will need to implement a prompt. This will only need to be done once for each user. Once a user consents, they do not need to reconsent unless they explicitly revoke access to your application.
Create a new file in the ./Pages directory named Authenticate.cshtml.cs and add the following code.
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace GraphTutorial.Pages { public class AuthenticateModel : PageModel { private readonly ILogger<IndexModel> _logger; public string ApplicationId { get; private set; } public string State { get; private set; } public string Nonce { get; private set; } public AuthenticateModel(IConfiguration configuration, ILogger<IndexModel> logger) { _logger = logger; // Read the application ID from the // configuration. This is used to build // the authorization URL for the consent prompt ApplicationId = configuration .GetSection("AzureAd") .GetValue<string>("ClientId"); // Generate a GUID for state and nonce State = System.Guid.NewGuid().ToString(); Nonce = System.Guid.NewGuid().ToString(); } } }
Create a new file in the ./Pages directory named Authenticate.cshtml and add the following code.
@page <!-- Copyright (c) Microsoft Corporation. Licensed under the MIT License. --> @model AuthenticateModel @section Scripts { <script> (function () { microsoftTeams.initialize(); // Save the state so it can be verified in // AuthComplete.cshtml localStorage.setItem('auth-state', '@Model.State'); // Get the context for tenant ID and login hint microsoftTeams.getContext((context) => { // Set all of the query parameters for an // authorization request const queryParams = { client_id: '@Model.ApplicationId', response_type: 'id_token token', response_mode: 'fragment', scope: 'https://graph.microsoft.com/.default openid', redirect_uri: `${window.location.origin}/authcomplete`, nonce: '@Model.Nonce', state: '@Model.State', login_hint: context.loginHint, }; // Generate the URL const authEndpoint = `https://login.microsoftonline.com/${context.tid}/oauth2/v2.0/authorize?${toQueryString(queryParams)}`; // Browse to the URL window.location.assign(authEndpoint); }); })(); // Helper function to build a query string from an object function toQueryString(queryParams) { let encodedQueryParams = []; for (let key in queryParams) { encodedQueryParams.push(key + '=' + encodeURIComponent(queryParams[key])); } return encodedQueryParams.join('&'); } </script> }
Create a new file in the ./Pages directory named AuthComplete.cshtml and add the following code.
@page <!-- Copyright (c) Microsoft Corporation. Licensed under the MIT License. --> @section Scripts { <script> (function () { microsoftTeams.initialize(); const hashParams = getHashParameters(); if (hashParams['error']) { microsoftTeams.authentication.notifyFailure(hashParams['error']); } else if (hashParams['access_token']) { // Check the state parameter const expectedState = localStorage.getItem('auth-state'); if (expectedState !== hashParams['state']) { microsoftTeams.authentication.notifyFailure('StateDoesNotMatch'); } else { // State parameter matches, report success localStorage.removeItem('auth-state'); microsoftTeams.authentication.notifySuccess('Success'); } } else { microsoftTeams.authentication.notifyFailure('NoTokenInResponse'); } })(); // Helper function to generate a hash from // a query string function getHashParameters() { let hashParams = {}; location.hash.substr(1).split('&').forEach(function(item) { let s = item.split('='), k = s[0], v = s[1] && decodeURIComponent(s[1]); hashParams[k] = v; }); return hashParams; } </script> }
Open ./Pages/Index.cshtml and add the following functions inside the
<script>
tag.function loadUserCalendar(token, callback) { // Call the API fetch('/calendar', { headers: { 'Authorization': `Bearer ${token}` } }).then(response => { if (response.ok) { // Get the JSON payload response.json() .then(events => { callback(events); }); } else if (response.status === 403) { response.text() .then(body => { // If the API sent 'consent_required' // we need to prompt the user if (body === 'consent_required') { promptForConsent((error) => { if (error) { renderError(error); } else { // Retry API call loadUserCalendar(token, callback); } }); } }); } }).catch(error => { renderError(error); }); } function promptForConsent(callback) { // Cause Teams to popup a window for consent microsoftTeams.authentication.authenticate({ url: `${window.location.origin}/authenticate`, width: 600, height: 535, successCallback: (result) => { callback(null); }, failureCallback: (error) => { callback(error); } }); }
Add the following function inside the
<script>
tag to display a successful result from the Web API.function renderCalendar(events) { $('#tab-container').empty(); $('<pre/>').append($('<code/>', { text: JSON.stringify(events, null, 2), style: 'word-break: break-all;' })).appendTo('#tab-container'); }
Replace the existing
successCallback
with the following code.successCallback: (token) => { loadUserCalendar(token, (events) => { renderCalendar(events); }); }
Save your changes and restart the app. Refresh the tab in Microsoft Teams. You should get a pop-up window asking for consent to the Microsoft Graph permissions scopes. After accepting, the tab should display
{ "status": "OK" }
.Note
If the tab displays
"FailedToOpenWindow"
, please disable pop-up blockers in your browser and reload the page.Review the log output. You should see the
Access token for Graph
entry. If you parse that token, you'll notice that it contains the Microsoft Graph scopes configured in appsettings.json.
Storing and refreshing tokens
At this point your application has an access token, which is sent in the Authorization
header of API calls. This is the token that allows the app to access Microsoft Graph on the user's behalf.
However, this token is short-lived. The token expires an hour after it is issued. This is where the refresh token becomes useful. The refresh token allows the app to request a new access token without requiring the user to sign in again.
Because the app is using the Microsoft.Identity.Web library, you do not have to implement any token storage or refresh logic.
The app uses the in-memory token cache, which is sufficient for apps that do not need to persist tokens when the app restarts. Production apps may instead use the distributed cache options in the Microsoft.Identity.Web library.
The GetAccessTokenForUserAsync
method handles token expiration and refresh for you. It first checks the cached token, and if it is not expired, it returns it. If it is expired, it uses the cached refresh token to obtain a new one.
The GraphServiceClient that controllers get via dependency injection is pre-configured with an authentication provider that uses GetAccessTokenForUserAsync
for you.
Get a calendar view
In this section you will incorporate Microsoft Graph into the application. For this application, you will use the Microsoft Graph Client Library for .NET to make calls to Microsoft Graph.
Get a calendar view
A calendar view is a set of events from the user's calendar that occur between two points of time. You'll use this to get the user's events for the current week.
Open ./Controllers/CalendarController.cs and add the following function to the CalendarController class.
private DateTime GetUtcStartOfWeekInTimeZone(DateTime today, string timeZoneId) { // Time zone returned by Graph could be Windows or IANA style // TimeZoneConverter can take either TimeZoneInfo userTimeZone = TZConvert.GetTimeZoneInfo(timeZoneId); // Assumes Sunday as first day of week int diff = System.DayOfWeek.Sunday - today.DayOfWeek; // create date as unspecified kind var unspecifiedStart = DateTime.SpecifyKind(today.AddDays(diff), DateTimeKind.Unspecified); // convert to UTC return TimeZoneInfo.ConvertTimeToUtc(unspecifiedStart, userTimeZone); }
Add the following function to handle exceptions returned from Microsoft Graph calls.
private ActionResult HandleGraphException(Exception exception) { if (exception is MicrosoftIdentityWebChallengeUserException || exception.InnerException is MicrosoftIdentityWebChallengeUserException) { _logger.LogError(exception, "Consent required"); // This exception indicates consent is required. // Return a 403 with "consent_required" in the body // to signal to the tab it needs to prompt for consent return new ContentResult { StatusCode = (int)HttpStatusCode.Forbidden, ContentType = "text/plain", Content = "consent_required" }; } else if (exception is ServiceException) { var serviceException = exception as ServiceException; _logger.LogError(serviceException, "Graph service error occurred"); return new ContentResult { StatusCode = (int)serviceException.StatusCode, ContentType = "text/plain", Content = serviceException.Error.ToString() }; } else { _logger.LogError(exception, "Error occurred"); return new ContentResult { StatusCode = (int)HttpStatusCode.InternalServerError, ContentType = "text/plain", Content = exception.ToString() }; } }
Replace the existing
Get
function with the following.[HttpGet] public async Task<ActionResult<IEnumerable<Event>>> Get() { // This verifies that the access_as_user scope is // present in the bearer token, throws if not HttpContext.VerifyUserHasAnyAcceptedScope(apiScopes); // To verify that the identity libraries have authenticated // based on the token, log the user's name _logger.LogInformation($"Authenticated user: {User.GetDisplayName()}"); try { // Get the user's mailbox settings var me = await _graphClient.Me .Request() .Select(u => new { u.MailboxSettings }) .GetAsync(); // Get the start and end of week in user's time // zone var startOfWeek = GetUtcStartOfWeekInTimeZone( DateTime.Today, me.MailboxSettings.TimeZone); var endOfWeek = startOfWeek.AddDays(7); // Set the start and end of the view var viewOptions = new List<QueryOption> { new QueryOption("startDateTime", startOfWeek.ToString("o")), new QueryOption("endDateTime", endOfWeek.ToString("o")) }; // Get the user's calendar view var results = await _graphClient.Me .CalendarView .Request(viewOptions) // Send user time zone in request so date/time in // response will be in preferred time zone .Header("Prefer", $"outlook.timezone=\"{me.MailboxSettings.TimeZone}\"") // Get max 50 per request .Top(50) // Only return fields app will use .Select(e => new { e.Subject, e.Organizer, e.Start, e.End, e.Location }) // Order results chronologically .OrderBy("start/dateTime") .GetAsync(); return Ok(results.CurrentPage); } catch (Exception ex) { return HandleGraphException(ex); } }
Review the changes. This new version of the function:
- Returns
IEnumerable<Event>
instead ofstring
. - Gets the user's mailbox settings using Microsoft Graph.
- Uses the user's time zone to calculate the start and end of the current week.
- Gets a calendar view
- Uses the
.Header()
function to include aPrefer: outlook.timezone
header, which causes the returned events to have their start and end times converted to the user's timezone. - Uses the
.Top()
function to request at most 50 events. - Uses the
.Select()
function to request just the fields used by the app. - Uses the
OrderBy()
function to sort the results by the start time.
- Uses the
- Returns
Save your changes and restart the app. Refresh the tab in Microsoft Teams. The app displays a JSON listing of the events.
Display the results
Now you can display the list of events in a more user friendly way.
Open ./Pages/Index.cshtml and add the following functions inside the
<script>
tag.function renderSubject(subject) { if (!subject || subject.length <= 0) { subject = '<No subject>'; } return $('<div/>', { class: 'ms-fontSize-18 ms-fontWeight-bold', text: subject }); } function renderOrganizer(organizer) { return $('<div/>', { class: 'ms-fontSize-14 ms-fontWeight-semilight', text: organizer.emailAddress.name }).append($('<i/>', { class: 'ms-Icon ms-Icon--PartyLeader', style: 'margin-right: 10px;' })); } function renderTimeSpan(start, end) { return $('<div/>', { class: 'ms-fontSize-14 ms-fontWeight-semilight', text: `${formatDateTime(start.dateTime)} - ${formatDateTime(end.dateTime)}` }).append($('<i/>', { class: 'ms-Icon ms-Icon--DateTime2', style: 'margin-right: 10px;' })); } function formatDateTime(dateTime) { const date = new Date(dateTime); // Format like 10/14/2020 4:00 PM let hours = date.getHours(); const minutes = date.getMinutes(); const ampm = hours >= 12 ? 'PM' : 'AM'; hours = hours % 12; hours = hours ? hours : 12; const minStr = minutes < 10 ? `0${minutes}` : minutes; return `${date.getMonth()+1}/${date.getDate()}/${date.getFullYear()} ${hours}:${minStr} ${ampm}`; } function renderLocation(location) { if (!location || location.displayName.length <= 0) { return null; } return $('<div/>', { class: 'ms-fontSize-14 ms-fontWeight-semilight', text: location.displayName }).append($('<i/>', { class: 'ms-Icon ms-Icon--MapPin', style: 'margin-right: 10px;' })); }
Replace the existing
renderCalendar
function with the following.function renderCalendar(events) { $('#tab-container').empty(); // Add title $('<div/>', { class: 'tab-title ms-fontSize-42', text: 'Week at a glance' }).appendTo('#tab-container'); // Render each event events.map(event => { const eventCard = $('<div/>', { class: 'event-card ms-depth-4', }); eventCard.append(renderSubject(event.subject)); eventCard.append(renderOrganizer(event.organizer)); eventCard.append(renderTimeSpan(event.start, event.end)); const location = renderLocation(event.location); if (location) { eventCard.append(location); } eventCard.appendTo('#tab-container'); }); }
Save your changes and restart the app. Refresh the tab in Microsoft Teams. The app displays events on the user's calendar.
Create a new event
In this section you will add the ability to create events on the user's calendar.
Create the new event tab
Create a new file in the ./Pages directory named NewEvent.cshtml and add the following code.
@page <!-- Copyright (c) Microsoft Corporation. Licensed under the MIT License. --> @{ ViewData["Title"] = "New event"; } <div class="form-container"> <form id="newEventForm"> <div class="ms-Grid" dir="ltr"> <div class="ms-Grid-row"> <div class="ms-Grid-col ms-sm12"> <label class="ms-fontWeight-semibold form-label" for="subject">Subject</label> <input class="form-input" type="text" id="subject" name="subject" /> </div> </div> <div class="ms-Grid-row"> <div class="ms-Grid-col ms-sm12"> <label class="ms-fontWeight-semibold form-label" for="attendees">Attendees</label> <input class="form-input" type="text" id="attendees" name="attendees" placeholder="Enter email addresses of attendees. Separate multiple with ';'. Leave blank for no attendees." /> </div> </div> <div class="ms-Grid-row"> <div class="ms-Grid-col ms-sm6"> <label class="ms-fontWeight-semibold form-label" for="start">Start</label> <input class="form-input" type="datetime-local" id="start" name="start" /> </div> <div class="ms-Grid-col ms-sm6"> <label class="ms-fontWeight-semibold form-label" for="end">End</label> <input class="form-input" type="datetime-local" id="end" name="end" /> </div> </div> <div class="ms-Grid-row"> <div class="ms-Grid-col ms-sm12"> <label class="ms-fontWeight-semibold form-label" for="body">Body</label> <textarea class="form-input" id="body" name="body" rows="4"></textarea> </div> </div> <input class="form-button" type="submit" value="Create"/> </div> </form> <div class="ms-depth-16 result-panel"></div> </div> @section Scripts { <script> (function () { if (microsoftTeams) { microsoftTeams.initialize(); } $('#newEventForm').on('submit', async (e) => { e.preventDefault(); $('.result-panel').empty(); $('.result-panel').hide(); const formData = new FormData(newEventForm); // Basic validation // Require subject, start, and end const subject = formData.get('subject'); const start = formData.get('start'); const end = formData.get('end'); if (subject.length <= 0 || start.length <= 0 || end.length <= 0) { $('<div/>', { class: 'error-msg', text: 'Subject, Start, and End are required.' }).appendTo('.result-panel'); $('.result-panel').show(); return; } // Get the auth token from Teams microsoftTeams.authentication.getAuthToken({ successCallback: (token) => { createEvent(token, formData); }, failureCallback: (error) => { $('<div/>', { class: 'error-msg', text: `Error getting token: ${error}` }).appendTo('.result-panel'); $('.result-panel').show(); } }); }); })(); async function createEvent(token, formData) { // Convert the form to a JSON payload jsonFormData = formDataToJson(); // Post the payload to the web API const response = await fetch('/calendar', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, method: 'POST', body: jsonFormData }); if (response.ok) { $('<div/>', { class: 'success-msg', text: 'Event added to your calendar' }).appendTo('.result-panel'); $('.result-panel').show(); } else { const error = await response.text(); $('<div/>', { class: 'error-msg', text: `Error creating event: ${error}` }).appendTo('.result-panel'); $('.result-panel').show(); } } // Helper method to serialize the form fields // as JSON function formDataToJson() { const array = $('#newEventForm').serializeArray(); const jsonObj = {}; array.forEach((kvp) => { jsonObj[kvp.name] = kvp.value; }); return JSON.stringify(jsonObj); } </script> }
This implements a simple form, and adds JavaScript to post the form data to the Web API.
Implement the Web API
Create a new directory named Models in the root of the project.
Create a new file in the ./Models directory named NewEvent.cs and add the following code.
namespace GraphTutorial.Models { public class NewEvent { public string Subject { get; set; } public string Attendees { get; set; } public string Start { get; set; } public string End { get; set; } public string Body { get; set; } } }
Open ./Controllers/CalendarController.cs and add the following
using
statement at the top of the file.using GraphTutorial.Models;
Add the following function to the CalendarController class.
[HttpPost] public async Task<ActionResult<string>> Post(NewEvent newEvent) { HttpContext.VerifyUserHasAnyAcceptedScope(apiScopes); try { // Get the user's mailbox settings var me = await _graphClient.Me .Request() .Select(u => new { u.MailboxSettings }) .GetAsync(); // Create a Graph Event var graphEvent = new Event { Subject = newEvent.Subject, Start = new DateTimeTimeZone { DateTime = newEvent.Start, TimeZone = me.MailboxSettings.TimeZone }, End = new DateTimeTimeZone { DateTime = newEvent.End, TimeZone = me.MailboxSettings.TimeZone } }; // If there are attendees, add them if (!string.IsNullOrEmpty(newEvent.Attendees)) { var attendees = new List<Attendee>(); var emailArray = newEvent.Attendees.Split(';'); foreach (var email in emailArray) { attendees.Add(new Attendee { Type = AttendeeType.Required, EmailAddress = new EmailAddress { Address = email } }); } graphEvent.Attendees = attendees; } // If there is a body, add it if (!string.IsNullOrEmpty(newEvent.Body)) { graphEvent.Body = new ItemBody { ContentType = BodyType.Text, Content = newEvent.Body }; } // Create the event await _graphClient.Me .Events .Request() .AddAsync(graphEvent); return Ok("success"); } catch (Exception ex) { return HandleGraphException(ex); } }
This allows an HTTP POST to the Web API with the fields of the form.
Save all of your changes and restart the application. Refresh the app in Microsoft Teams, and select the Create event tab. Fill out the form and select Create to add an event to the user's calendar.
Congratulations!
You've completed the Microsoft Teams app with 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.