共用方式為


Call O365 Exchange Online API from a SharePoint App

This post will show how to call an O365 Exchange Online API from a SharePoint provider-hosted app.  The code for this post is available at https://github.com/kaevans/spapp-exchange

Background

This post is part of a series on building a SharePoint app that communicate with services protected by Azure AD.

In my post, An Architecture for SharePoint Apps That Call Other Services, I proposed an architecture to enable SharePoint provider-hosted apps to access additional services.  I showed that the access token used for SharePoint apps is not reusable for other services and accessing additional services requires a second access token.  In my post, Using OpenID Connect with SharePoint Apps, I showed how to authenticate a user against the same Azure AD tenant that O365 uses, providing a seamless sign-on experience for the user.  This post shows the next step, accessing a service that is protected by Azure AD.  For this post, we will show to call the O365 Exchange Online API. 

The key to this is that we have at least two access tokens: one issued by Azure ACS used with my SharePoint app, and one issued by Azure AD for the O365 Exchange Online API.

image

In reality, we will use 4 different access tokens in this post. 

image

This post will be similar to the walkthrough Integrate Office 365 APIs into .NET Visual Studio projects, with a difference that we are using a SharePoint provider-hosted app that requires the SystemWebCookieManager implementation. 

Authenticate with OpenID Connect

The first step is to create a new SharePoint provider-hosted app and authenticate the user with OpenID Connect.  I showed the steps for this in the post Using OpenID Connect with SharePoint Apps.  The high-level steps are:

  • Create the new provider-hosted app
  • Add NuGet packages
  • Add OWIN startup class
  • Implement the SystemWebCookieManager class
  • Add the OWIN middleware
  • Register the application with Azure AD
  • Update web.config

Again, the steps are written (with screen shots) at Using OpenID Connect with SharePoint Apps.  Once you are done, your web.config will look something like:

image

Your project structure will look like:

image

Reference O365 Exchange API

Now that you’ve added OpenID Connect authentication to your SharePoint app, the next step is to reference the O365 Exchange Online API.  The tooling in Visual Studio makes this pretty easy.  Just right-click the web project and choose Add Connected Service

image

The next screen prompts you to sign in.  Sign into the same O365 tenant that you are using for development of your provider-hosted app.

image

You are then prompted if you want to add the following redirect URLs.  Click Yes.

image

Click App Properties.

image

Since you’ve already registered the application with Azure AD, the tooling picks it up (based on the client ID) and shows the properties.  You do not need the HTTP endpoint for the app, only the HTTPS endpoint.  You can safely delete the HTTP endpoint, leaving only the HTTPS endpoint.

image

Note that you could also make this application available to multiple organizations instead of a single organization.  This would enable a scenario where the application is going to be published to the Store and your application is servicing thousands of tenants.

Click Apply.  Next, select the Mail service, then click Permissions.

image

Our application is going to read the currently logged on user’s email.  Check Read users’ mail.

image

In case you are interested what just happened, some NuGet packages were added to your web application. 

image

Additionally, if you go to the application in Azure Active Directory, you can see that the reply URL was configured according to the values we set in the App Properties window, but more importantly the app was granted permission to the Office 365 Exchange Online API. 

image

Another valuable thing to point out is that a new page should be visible in your browser, Integrate Office 365 APIs into .NET Visual Studio projects.  We will use portions of that post for our solution.

Add a Token Cache

As explained in my previous posting, Call Multiple Services With One Login Prompt Using ADAL, Azure AD implements a multiple-resource refresh token that allows you to call multiple services without prompting the user for each service.  When using the Azure AD Authentication Library (ADAL) with a client application, it is able to cache the token locally.  When using with a web application, you should implement the cache yourself.  There is an open-source implementation of an ADALTokenCache and its accompanying ApplicationDbContext to use with Entity Framework available on GitHub.

Add the Entity Framework NuGet package and the ADAL NuGet package.

 Install-Package EntityFramework
Install-Package Microsoft.IdentityModel.Clients.ActiveDirectory

Add a connection string for a database that will store the tokens.

Token Cache Connetion String

  1. <connectionStrings>
  2.     <add name="DefaultConnection"
  3.          connectionString="Data Source=(LocalDB)\v11.0;AttachDbFilename=|DataDirectory|\ADALTokenCacheDb.mdf;Integrated Security=True"
  4.          providerName="System.Data.SqlClient" />
  5.   </connectionStrings>

Copy the code for the ADALTokenCache and ApplicationDbContext to your Models directory.

image

Authenticate with OpenID Connect and Obtain an Access Token

When we authenticate with OpenID Connect, we receive a code.  From that code, we can obtain an access token to the Azure AD Graph API and store the token in our cache.  To call the Azure AD Graph API, we have to obtain a client secret for our Azure AD application.  Go to the management portal and add a new key for the application.

image

After you hit Save, the key will be displayed.  Copy that value into an appSetting in web.config named “ida:AppKey”.  Also add the appSetting for GraphResourceID with the value “https://graph.windows.net”.

appSettings in Web.config

  1. <appSettings>
  2.   <add key="webpages:Version" value="3.0.0.0" />
  3.   <add key="webpages:Enabled" value="false" />
  4.   <add key="ClientValidationEnabled" value="true" />
  5.   <add key="UnobtrusiveJavaScriptEnabled" value="true" />
  6.   
  7.   <!-- SharePoint OAuth -->
  8.   <add key="ClientId" value="" />
  9.   <add key="ClientSecret" value="mPnwb/0rRRILiDLMDJtCGdywy/qMYRXneJ9AEurIeBA=" />
  10.   
  11.   <!-- Azure AD OAuth -->
  12.   <add key="ida:ClientID" value="77d50962-3a4d-461e-976b-cacad345a11c" />
  13.   <add key="ida:AppKey" value="YOUR_APPKEY_HERE (example mPnwb/0rRRILiDLMDJtCGdywy/qMYRXneJ9AEurIeBA=)"/>
  14.   <add key="ida:AADInstance" value="https://login.windows.net/{0}" />
  15.   <add key="ida:Tenant" value="kirke3.onmicrosoft.com" />
  16.   <add key="ida:GraphResourceID" value="https://graph.windows.net"/>
  17. </appSettings>

Update the ConfigureAuth method in the Startup.Auth.cs file. 

Startup.Auth.cs

  1. using ExchangeDemoWeb.Models;
  2. using ExchangeDemoWeb.Utils;
  3. using Microsoft.IdentityModel.Clients.ActiveDirectory;
  4. using Microsoft.Owin.Security;
  5. using Microsoft.Owin.Security.Cookies;
  6. using Microsoft.Owin.Security.OpenIdConnect;
  7. using Owin;
  8. using System;
  9. using System.Configuration;
  10. using System.Globalization;
  11. using System.IdentityModel.Claims;
  12. using System.Threading.Tasks;
  13. using System.Web;
  14.  
  15. namespace ExchangeDemoWeb
  16. {
  17.     public partial class Startup
  18.     {
  19.         public void ConfigureAuth(IAppBuilder app)
  20.         {
  21.             app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
  22.  
  23.             app.UseCookieAuthentication(new CookieAuthenticationOptions
  24.             {
  25.                 //Implement our own cookie manager to work around the infinite
  26.                 //redirect loop issue
  27.                 CookieManager = new SystemWebCookieManager()
  28.             });
  29.  
  30.             string clientID = ConfigurationManager.AppSettings["ida:ClientID"];
  31.             string aadInstance = ConfigurationManager.AppSettings["ida:AADInstance"];
  32.             string tenant = ConfigurationManager.AppSettings["ida:Tenant"];
  33.             string clientSecret = ConfigurationManager.AppSettings["ida:AppKey"];
  34.             string graphResourceID = ConfigurationManager.AppSettings["ida:GraphResourceID"];
  35.  
  36.             string authority = string.Format(CultureInfo.InvariantCulture, aadInstance, tenant);
  37.  
  38.             app.UseOpenIdConnectAuthentication(
  39.                 new OpenIdConnectAuthenticationOptions
  40.                 {
  41.                     ClientId = clientID,
  42.                     Authority = authority,
  43.  
  44.                     Notifications = new OpenIdConnectAuthenticationNotifications()
  45.                     {
  46.                         // when an auth code is received...
  47.                         AuthorizationCodeReceived = (context) =>
  48.                         {
  49.                             // get the OpenID Connect code passed from Azure AD on successful auth
  50.                             string code = context.Code;
  51.  
  52.                             // create the app credentials & get reference to the user
  53.                             ClientCredential creds = new ClientCredential(clientID, clientSecret);                            
  54.                             string signInUserId = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.NameIdentifier).Value;
  55.  
  56.                             // use the OpenID Connect code to obtain access token & refresh token...
  57.                             //  save those in a persistent store...
  58.                             AuthenticationContext authContext = new AuthenticationContext(authority, new ADALTokenCache(signInUserId));
  59.  
  60.                             // obtain access token for the AzureAD graph
  61.                             Uri redirectUri = new Uri(HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Path));
  62.                             AuthenticationResult authResult = authContext.AcquireTokenByAuthorizationCode(code, redirectUri, creds, graphResourceID);
  63.  
  64.                             // successful auth                            
  65.                             return Task.FromResult(0);
  66.                         },
  67.                         AuthenticationFailed = (context) =>
  68.                         {
  69.                             context.HandleResponse();
  70.                             return Task.FromResult(0);
  71.                         }
  72.                     }
  73.  
  74.                 });
  75.         }
  76.     }
  77. }

Finally, I add an Authorize attribute to the About method that Visual Studio generated in the HomeController.

image

You are at a point that you can hit F5 and test the application so far.  If everything goes as expected, you should be able to set a breakpoint in the Startup.Auth.cs file and see the access token is returned, and you can go into the SQL Database and validate you see an entry for the user.

image

Finally…FINALLY… Call Exchange API

There has been quite a bit of digital ink spilled up to this point, but what we have is all the plumbing that makes this solution possible in addition to a firmer explanation of all the moving pieces.  Rather than just dump a solution on you, you hopefully understand why all the parts are needed.

That said… we can finally call the Exchange API.

Add a new class to the Models folder named “MyMessage”.

MyMessage.cs

  1.  
  2. namespace ExchangeDemoWeb.Models
  3. {
  4.     public class MyMessage
  5.     {
  6.         public string Subject { get; set; }
  7.         public string From { get; set; }
  8.     }
  9. }

Right-click the Controllers folder and choose Add / Controller.  Choose MVC 5 Controller – Empty.

image

Name it MailController.

image

Update the code for the MailController.  Notice that the Index action is now asynchronous, and we’ve applied the [Authorize] attribute to the class, ensuring the user is logged on prior to accessing the action.  We are using the Discovery service to discover the user’s mailbox information without hard-coding a reference to the organization in any way.  This allows us to build this solution in a multi-tenant fashion.

MailController.cs

  1. using ExchangeDemoWeb.Models;
  2. using Microsoft.IdentityModel.Clients.ActiveDirectory;
  3. using Microsoft.Office365.Discovery;
  4. using Microsoft.Office365.OutlookServices;
  5. using System;
  6. using System.Collections.Generic;
  7. using System.Configuration;
  8. using System.Globalization;
  9. using System.Security.Claims;
  10. using System.Threading.Tasks;
  11. using System.Web.Mvc;
  12.  
  13. namespace ExchangeDemoWeb.Controllers
  14. {
  15.     [Authorize]
  16.     public class MailController : Controller
  17.     {
  18.         // GET: Mail
  19.         public async Task<ActionResult> Index()
  20.         {
  21.             string clientID = ConfigurationManager.AppSettings["ida:ClientID"];
  22.             string aadInstance = ConfigurationManager.AppSettings["ida:AADInstance"];
  23.             string tenant = ConfigurationManager.AppSettings["ida:Tenant"];
  24.             string clientSecret = ConfigurationManager.AppSettings["ida:AppKey"];
  25.             string graphResourceID = ConfigurationManager.AppSettings["ida:GraphResourceID"];
  26.  
  27.  
  28.             string discoveryResourceID = "https://api.office.com/discovery/";
  29.             string discoveryServiceEndpointUri = "https://api.office.com/discovery/v1.0/me/";
  30.  
  31.             string authority = string.Format(CultureInfo.InvariantCulture, aadInstance, tenant);
  32.  
  33.             List<MyMessage> myMessages = new List<MyMessage>();
  34.  
  35.             var signInUserId = ClaimsPrincipal.Current.FindFirst(ClaimTypes.NameIdentifier).Value;
  36.             var userObjectId = ClaimsPrincipal.Current.FindFirst("https://schemas.microsoft.com/identity/claims/objectidentifier").Value;
  37.  
  38.             //Create an authentication context from cache
  39.             AuthenticationContext authContext = new AuthenticationContext(authority, new ADALTokenCache(signInUserId));
  40.  
  41.             try
  42.             {
  43.                 DiscoveryClient discClient = new DiscoveryClient(new Uri(discoveryServiceEndpointUri),
  44.                     async () =>
  45.                     {
  46.                         //Get an access token to the discovery service
  47.                         var authResult = await authContext.AcquireTokenSilentAsync(discoveryResourceID, new ClientCredential(clientID, clientSecret), new UserIdentifier(userObjectId, UserIdentifierType.UniqueId));
  48.  
  49.                         return authResult.AccessToken;
  50.                     });
  51.  
  52.                 var dcr = await discClient.DiscoverCapabilityAsync("Mail");
  53.  
  54.                 OutlookServicesClient exClient = new OutlookServicesClient(dcr.ServiceEndpointUri,
  55.                     async () =>
  56.                     {
  57.                         //Get an access token to the Messages
  58.                         var authResult = await authContext.AcquireTokenSilentAsync(dcr.ServiceResourceId, new ClientCredential(clientID, clientSecret), new UserIdentifier(userObjectId, UserIdentifierType.UniqueId));
  59.  
  60.                         return authResult.AccessToken;
  61.                     });
  62.  
  63.                 var messagesResult = await exClient.Me.Messages.ExecuteAsync();
  64.  
  65.                 do
  66.                 {
  67.                     var messages = messagesResult.CurrentPage;
  68.                     foreach (var message in messages)
  69.                     {
  70.  
  71.                         myMessages.Add(new MyMessage
  72.                         {
  73.                             Subject = message.Subject,
  74.                             From = message.Sender.EmailAddress.Address
  75.                         });
  76.                     }
  77.  
  78.                     messagesResult = await messagesResult.GetNextPageAsync();
  79.  
  80.                 } while (messagesResult != null);
  81.             }
  82.             catch (AdalException exception)
  83.             {
  84.                 //handle token acquisition failure
  85.                 if (exception.ErrorCode == AdalError.FailedToAcquireTokenSilently)
  86.                 {
  87.                     authContext.TokenCache.Clear();
  88.  
  89.                     //handle token acquisition failure
  90.                 }
  91.             }
  92.  
  93.             return View(myMessages);
  94.         }
  95.  
  96.     }
  97. }

Now we need a way to view the data.  Right-click the Mail folder under the Views folder and choose “Add/View”.  The new view is named “Index”, the template is “List”, and the model class is our “MyMessage” class.

image

We also need to add a link to our Mail controller in the navigation for the app.  Go to the Views/Shared/_Layout.cshtml file and update the nav bar.

Code Snippet

  1. <div class="navbar-collapse collapse">
  2.     <ul class="nav navbar-nav">
  3.         <li>@Html.ActionLink("Home", "Index", "Home")</li>
  4.         <li>@Html.ActionLink("About", "About", "Home")</li>
  5.         <li>@Html.ActionLink("Contact", "Contact", "Home")</li>
  6.         <li>@Html.ActionLink("My Messages", "Index", "Mail")</li>
  7.     </ul>
  8. </div>

Testing… Testing… Is This Thing On?

The big payoff… hit F5.  You are prompted to sign into O365 because this is a SharePoint provider hosted app. 

image

Trust the SharePoint app.

image

We are now looking at our SharePoint provider-hosted app, the same one you’ve been using for awhile now.

image

Click the “My Messages” link in the navbar.  Notice there is a redirect, but the user is not asked for additional credentials.  And voila!  We have successfully called the Exchange mail API on behalf of the current user.

image

I can verify that the emails shown in the screen match the emails in my inbox.

image

And just for completeness… we can copy the URL to the “My Messages” link, including all the SPHostUrl stuff in the querystring, and open an in-private browsing session.  Paste the URL.  This time you are prompted to sign into Azure AD, not O365.

image

The messages are properly displayed.

image

And I can click the “Home” link, which goes to the typical SharePoint provider-hosted app stuff that I am accustomed to. 

image

Summary

This post showed how to create a SharePoint provider-hosted app that authenticates against Azure AD using OpenID Connect and then accesses the currently logged in user’s mailbox to display messages.  The key to this was using the same Azure AD directory that our Exchange API uses.  This provides a seamless sign-on experience for the user.

image

The code for this post is available at https://github.com/kaevans/spapp-exchange

For More Information

An Architecture for SharePoint Apps That Call Other Services

Using OpenID Connect with SharePoint Apps

ADALTokenCache

ApplicationDbContext

Integrate Office 365 APIs into .NET Visual Studio projects

https://github.com/kaevans/spapp-exchange

Comments

  • Anonymous
    March 24, 2015
    Awesome work Kirk! Really useful and showing how the O365 API's and the SharePoint client side API's can be combined.

  • Anonymous
    March 30, 2015
    Thanks Kirk for this wonderful post!!

  • Anonymous
    April 01, 2015
    I have been looking at similar posts online, nothing comes close to this level of detail and clearness. Two thumbs up!  :)

  • Anonymous
    April 06, 2015
    Hi Kirk, Great article, thanks! I'm trying to get this to work when calling EWS Managed Api (using the oAuthCredentials class) I keep getting either 401s or 403s. So it appears the Access Token Claims don't really match for Managed API and the 365 API? Any ideas on this? Martin

  • Anonymous
    April 07, 2015
    @Martin - stackoverflow.com/.../office-365-ews-authentication-using-oauth

  • Anonymous
    April 07, 2015
    Hi Kirk, I did read that post beforehand. I am using the oAuthCredentials class, and I checked all the boxes in Delegated dropdown (in the azure app) I am an Administrator for my Tenant, so that means after it prompts me for access to the app, it should grant me access, right? After silently logging on and granting access to the app, it still gives me a 401 error. Any ideas? Should I add &prompt=admin_consent to the Logon url or something? In effect your saying that it should work. (thats also important to note :)

  • Anonymous
    April 07, 2015
    I forget to say that the REST Api's are working as expected, so the access token is valid and in use for that.

  • Anonymous
    April 09, 2015
    Looks like it is all documented here. msdn.microsoft.com/.../dn903761(v=exchg.150).aspx  

  • Anonymous
    April 12, 2015
    Okay thanks. I did read that as well beforehand. but it appears I've been ignoring your AdalTokenCache in my custom EWS call. That in combination with some use of the wrong AcquireToken() approach apparently resulted in the errors. Anyway, got it working now, thanks.

  • Anonymous
    April 29, 2015
    Thanks Kirk!  This is exactly what I have been looking for although my scenario is slightly different but I'm hoping the same pattern will work.  I have built an office app (for outlook) deployed to O365 and in that app I'm trying to grab some list items from SharePoint or save items to SharePoint.  Will the authentication work the same way as described here?  Thanks! -Paul

  • Anonymous
    April 29, 2015
    @Paul - I haven't tried, but if you get it to work please leave a comment here with a link to a blog posting for details!