Поделиться через


A Sample SharePoint App That Calls A Custom Web API

This post will show how to create a Web API that calls other services on behalf of the current user.

Background

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

This post will show how to create a Web API that calls other services, such as the O365 Exchange Online API, on behalf of the current user.  I wrote about an implementation of this previously in the post Calling O365 APIs from your Web API on behalf of a user.  That post is outdated a bit, and showed the context of a Windows desktop app.  I am going to start with the code that was described in the post Call O365 Exchange Online API from a SharePoint App, and we’ll use the code from that post (https://github.com/kaevans/spapp-exchange/tree/v1.0) as a starting point.

The final solution for this post is available on GitHub - https://github.com/kaevans/spapp-webapi-exchange.

As a reminder, our starting point looks like this:

image

The solution we will build will create a single Web API, making it easy for multiple types of clients to consume it.  Our SharePoint app will request a token to call the Web API, and the Web API will request multiple tokens to call downstream services on behalf of the current user.

image

Think about how cool this is.  If we tried to do this all on-premises, we’d likely be looking at implementing Kerberos constrained delegation and fighting with the directory team in our company to add SPNs for the service endpoints.  Instead, we are able to achieve this simply by registering applications with Azure AD and using OAuth2 and Open ID Connect.

Create the Web API Project

Right-click the solution in Visual Studio and add a new project.  Choose “ASP.NET Web Application” and name it “ExchangeDemoAPI”.

image

Choose the Web API template, and change the authentication type to “Organizational Account”.  Provide the name of your Azure AD tenant, such as “kirke3.onmicrosoft.com”.

image

image

You are prompted to sign in as an administrator in order to register the application in Azure AD.

image

Note:  If you are not an administrator and your tenant administrator has enabled it for your tenant, you can register the app manually as I showed in the post Using OpenID Connect with SharePoint Apps.

You now have three projects:  The SharePoint app, the ASP.NET MVC web project, and a Web API project.

image

Even better, the tooling took care of the OWIN middleware stuff for us that we had to do by hand for the previous posts.

image

Go to the Azure Management Portal (https://manage.windowsazure.com) and see that a new application was created.

image

Go to the Configure tab and copy the client ID and create a new key for the application.

image

Copy those to web.config, providing your own values:

  • ida:Tenant – The Azure AD tenant
  • ida:Audience – The APP ID URI for your Web API application in Azure AD
  • ida:ClientID – The Client ID for your Web API application in Azure AD
  • ida:AppKey – The key created above

For example:

image

Manage Permissions for the Web API

Our Web API is going to call multiple services including the O365 Exchange Online API and the Azure AD Graph API and will do so on behalf of the current user.  Go to the Configure tab for the Web API project and scroll to the bottom to see the permissions.  Choose Add Application, and in the selection window choose the Office 365 Exchange Online application.

image

We then go into Delegated Permissions and allow the app to read a user’s email and have full control of a user’s calendar.

image

Notice that the app already had permission to enable sign-on and read users’ profiles from Azure Active Directory, that permission is granted by default.

Make sure to click Save.

Manage Permission for the Web Application

Now that we’ve created the Web API, we need to grant permissions for the ASP.NET MVC web application to call it.  In the post Using OpenID Connect with SharePoint Apps, I registered an application named “MyProviderHostedApp”, which is the ASP.NET MVC web application for our solution.  We will adjust its permissions, removing the ability to call the O365 Exchange Online API directly, and adding the ability to call our custom Web API.

image

image

The application is granted permission to delegate credentials by default without additional configuration.  Note that it is possible to add additional permissions for your Web API.  You can find documentation for the changes to make to the manifest in the post Adding, Updating, and Removing an Application.

A bit of transparency here: for some reason, the web API was not visible in the “Permission to other applications” dialog. I simply copied the values for the application, deleted it, and created a new application using the same values and then it worked. Maybe a glitch in the matrix…

Now our web application has permission to call the Web API on behalf of the current user, and the Web API has permission to call additional services on behalf of the current user.

Update the Web Application

In our previous post, we made a call to the Graph API in order to obtain an access token.  Our web application now only needs permission to the Web API.  You can see this around line 61 below.

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.             
  35.             string webAPIResourceID = "https://kirke3.onmicrosoft.com/ExchangeDemoAPI";
  36.  
  37.             string authority = string.Format(CultureInfo.InvariantCulture, aadInstance, tenant);
  38.  
  39.             app.UseOpenIdConnectAuthentication(
  40.                 new OpenIdConnectAuthenticationOptions
  41.                 {
  42.                     ClientId = clientID,
  43.                     Authority = authority,
  44.  
  45.                     Notifications = new OpenIdConnectAuthenticationNotifications()
  46.                     {
  47.                         // when an auth code is received...
  48.                         AuthorizationCodeReceived = (context) =>
  49.                         {
  50.                             // get the OpenID Connect code passed from Azure AD on successful auth
  51.                             string code = context.Code;
  52.  
  53.                             // create the app credentials & get reference to the user
  54.                             ClientCredential creds = new ClientCredential(clientID, clientSecret);                            
  55.                             string signInUserId = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.NameIdentifier).Value;
  56.  
  57.                             // use the OpenID Connect code to obtain access token & refresh token...
  58.                             //  save those in a persistent store...
  59.                             AuthenticationContext authContext = new AuthenticationContext(authority, new ADALTokenCache(signInUserId));
  60.  
  61.                             // obtain access token for the Web API
  62.                             Uri redirectUri = new Uri(HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Path));                            
  63.                             AuthenticationResult authResult = authContext.AcquireTokenByAuthorizationCode(code, redirectUri, creds, webAPIResourceID);
  64.                         
  65.                             // successful auth                            
  66.                             return Task.FromResult(0);
  67.                         },
  68.                         AuthenticationFailed = (context) =>
  69.                         {
  70.                             context.HandleResponse();
  71.                             return Task.FromResult(0);
  72.                         }
  73.                     }
  74.  
  75.                 });
  76.         }
  77.     }
  78. }

I added a new entry to point to the URL for our Web API implementation.

image

The next step is to change the MailController class for the web application to call our Web API, adding the Authorization header.

MailController.cs

  1. using ExchangeDemoWeb.Models;
  2. using Microsoft.IdentityModel.Clients.ActiveDirectory;
  3. using Newtonsoft.Json;
  4. using System;
  5. using System.Collections.Generic;
  6. using System.Configuration;
  7. using System.Globalization;
  8. using System.Net.Http;
  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.             var myMessages = new List<MyMessage>();
  22.  
  23.             string clientID = ConfigurationManager.AppSettings["ida:ClientID"];
  24.             string aadInstance = ConfigurationManager.AppSettings["ida:AADInstance"];
  25.             string tenant = ConfigurationManager.AppSettings["ida:Tenant"];
  26.             string clientSecret = ConfigurationManager.AppSettings["ida:AppKey"];            
  27.  
  28.             string webAPIResourceID = "https://kirke3.onmicrosoft.com/ExchangeDemoAPI";
  29.             string webAPIEndpoint = ConfigurationManager.AppSettings["webAPIEndpoint"];
  30.  
  31.             string authority = string.Format(CultureInfo.InvariantCulture, aadInstance, tenant);
  32.             
  33.             var signInUserId = ClaimsPrincipal.Current.FindFirst(ClaimTypes.NameIdentifier).Value;
  34.             var userObjectId = ClaimsPrincipal.Current.FindFirst("https://schemas.microsoft.com/identity/claims/objectidentifier").Value;
  35.             
  36.             try
  37.             {
  38.                 var clientCredential = new ClientCredential(clientID, clientSecret);
  39.                 AuthenticationContext authContext = new AuthenticationContext(authority, new ADALTokenCache(signInUserId));
  40.                 var authResult = await authContext.AcquireTokenAsync(
  41.                     webAPIResourceID,
  42.                     clientCredential,
  43.                     new UserAssertion(userObjectId, UserIdentifierType.UniqueId.ToString()));
  44.                 
  45.                 var client = new HttpClient();
  46.                 var request = new HttpRequestMessage(HttpMethod.Get, webAPIEndpoint);
  47.                 request.Headers.TryAddWithoutValidation("Authorization", authResult.CreateAuthorizationHeader());
  48.                 var response = await client.SendAsync(request);
  49.  
  50.                 var responseString = await response.Content.ReadAsStringAsync();
  51.  
  52.                 var responseMessages = JsonConvert.DeserializeObject<IEnumerable<MyMessage>>(responseString);
  53.                 myMessages = new List<MyMessage>(responseMessages);
  54.             }
  55.             catch(Exception oops)
  56.             {
  57.                 throw oops;
  58.             }
  59.  
  60.             return View(myMessages);
  61.         }
  62.  
  63.     }
  64. }

 

Update the Web API Project

Add the following NuGet packages to the Web API project:

  • Microsoft.IdentityModel.Clients.ActiveDirectory
  • EntityFramework

Right-click the Web API project and choose “Add Connected Service”.  Log in.

image

You can verify the permissions that we assigned previously.

image

When you click OK, the NuGet packages should be added to the project.  If not, deselect the permissions, then select them again and the tool will pick up the change.

Just like we did previously, copy the code for the ADALTokenCache and ApplicationDbContext to your Models directory.  Add a connection string to your Web.config.

connectionStrings

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

Add a new class, “MyMessages”, to the Models folder.

MyMessages

  1.  
  2. namespace ExchangeDemoAPI.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 add a new Web API 2.1 Empty controller named MailController.

image

image

image

Replace the code for MailController.cs with the following.

MailController.cs

  1. using ExchangeDemoAPI.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.Linq;
  10. using System.Net;
  11. using System.Net.Http;
  12. using System.Security.Claims;
  13. using System.Threading.Tasks;
  14. using System.Web;
  15. using System.Web.Http;
  16.  
  17. namespace ExchangeDemoAPI.Controllers
  18. {
  19.     [Authorize]
  20.     public class MailController : ApiController
  21.     {
  22.         public async Task<IHttpActionResult> GetMessages()
  23.         {
  24.             string clientID = ConfigurationManager.AppSettings["ida:ClientID"];
  25.             string aadInstance = ConfigurationManager.AppSettings["ida:AADInstance"];
  26.             string tenant = ConfigurationManager.AppSettings["ida:Tenant"];
  27.             string clientSecret = ConfigurationManager.AppSettings["ida:AppKey"];            
  28.  
  29.             //string graphResourceID = "https://graph.windows.net";
  30.             string discoveryResourceID = "https://api.office.com/discovery/";
  31.             string discoveryServiceEndpointUri = "https://api.office.com/discovery/v1.0/me/";
  32.  
  33.             string authority = string.Format(CultureInfo.InvariantCulture, aadInstance, tenant);
  34.  
  35.             List<MyMessage> myMessages = new List<MyMessage>();
  36.  
  37.             var signInUserId = ClaimsPrincipal.Current.FindFirst(ClaimTypes.NameIdentifier).Value;            
  38.  
  39.             //Get the access token from the request and form a new user assertion
  40.             string authHeader = HttpContext.Current.Request.Headers["Authorization"];
  41.             string userAccessToken = authHeader.Substring(authHeader.LastIndexOf(' ')).Trim();
  42.             UserAssertion userAssertion = new UserAssertion(userAccessToken);
  43.  
  44.             //Create an authentication context from cache
  45.             AuthenticationContext authContext = new AuthenticationContext(
  46.                 authority,
  47.                 new ADALTokenCache(signInUserId));
  48.             
  49.             try
  50.             {
  51.                 DiscoveryClient discClient = new DiscoveryClient(new Uri(discoveryServiceEndpointUri),
  52.                     async () =>
  53.                     {
  54.                         //Get an access token to the discovery service asserting the
  55.                         //credentials of the caller... this is how we achieve "on behalf of"
  56.                         var authResult = await authContext.AcquireTokenAsync(
  57.                             discoveryResourceID,
  58.                             new ClientCredential(clientID, clientSecret),
  59.                             userAssertion);
  60.  
  61.                         return authResult.AccessToken;
  62.                     });
  63.  
  64.                 var dcr = await discClient.DiscoverCapabilityAsync("Mail");
  65.  
  66.                 OutlookServicesClient exClient = new OutlookServicesClient(dcr.ServiceEndpointUri,
  67.                     async () =>
  68.                     {
  69.                         //Get an access token to the Messages asserting the
  70.                         //credentials of the caller... this is how we achieve "on behalf of"
  71.                         var authResult = await authContext.AcquireTokenAsync(
  72.                             dcr.ServiceResourceId,
  73.                             new ClientCredential(clientID, clientSecret),
  74.                             userAssertion);
  75.  
  76.                         return authResult.AccessToken;
  77.                     });
  78.  
  79.                 var messagesResult = await exClient.Me.Messages.ExecuteAsync();
  80.  
  81.                 do
  82.                 {
  83.                     var messages = messagesResult.CurrentPage;
  84.                     foreach (var message in messages)
  85.                     {
  86.  
  87.                         myMessages.Add(new MyMessage
  88.                         {
  89.                             Subject = message.Subject,
  90.                             From = message.Sender.EmailAddress.Address
  91.                         });
  92.                     }
  93.  
  94.                     messagesResult = await messagesResult.GetNextPageAsync();
  95.  
  96.                 } while (messagesResult != null);
  97.             }
  98.             catch (AdalException exception)
  99.             {
  100.                 throw exception;
  101.             }
  102.  
  103.             return Ok(myMessages);
  104.         }
  105.     }
  106. }

Lines 40-42 is the location where we obtain the access token that is sent to the Web API and form a new user assertion.  We then pass that user assertion when we request an access token.  This is how we call a service on behalf of the calling user.  The rest of the code is very straightforward:  we call the Discovery Service to discover the Mail capabilities, then call the O365 Exchange Online API endpoint that corresponds to the user’s tenancy to obtain their email messages.

Summary

Now that we have changed the architecture to use a Web API instead of calling the backend services directly, we can implement services within our Web API tier such as additional caching, validation, data augmentation, and data transformation.

image

This gives us the flexibility to implement logic within the service layer without propagating similar logic to all clients that access our Web API.  Our Web API is able to be used by other platforms such as PHP, Java, or Node.js,  and can even be called from a single-page application once we enable the implicit grant flow.  The next step, then is to implement a few client applications.

The final solution for this post is available on GitHub - https://github.com/kaevans/spapp-webapi-exchange.

For More Information

Using OpenID Connect with SharePoint Apps – authenticating the web application using OpenID Connect

Call O365 Exchange Online API from a SharePoint App – similar to this post, but does not use an interim Web API

Adding, Updating, and Removing an Application – shows the additional settings possible for exposing a Web API to other applications in Azure AD.

WebAPI-OnBehalfOf-DotNet – sample from the Azure AD team showing how to call the Azure AD Graph API from a custom Web API on behalf of the calling user.

https://github.com/kaevans/spapp-webapi-exchange – source code for this post

Comments

  • Anonymous
    March 25, 2015
    What is the use of custom API? Why do you make complex code to achieve something silly?

  • Anonymous
    March 25, 2015
    See blogs.msdn.com/.../an-architecture-for-sharepoint-apps-that-call-other-services.aspx.  The use of O365 Exchange Online API was but one example.  Your Web API can act as a gateway to integrate securely with many different systems.  The point is that you are able to do this on behalf of the current user, and can now expose that same Web API to many different types of clients.  Hardly "silly".  

  • Anonymous
    April 22, 2015
    The comment has been removed

  • Anonymous
    June 09, 2015
    Obvious troll is obvious. NEVER FALL PREY TO THE TROLL. Good article. We have a custom web api too that gets data from a backend SQL database and also makes csom calls into SharePoint (App principal only). We use javascript to make calls into the web api and display info in SharePoint. The same API is also used by our mobile application. We were looking for a way to secure the Web API and this seems like what we need. Thanks.

  • Anonymous
    June 29, 2015
    Love it. Really cool articles. I like the way you think about architecture on these solutions. Considering the multiple end points one may need to hit, and they not always being Microsoft endpoints in an enterprise, the method of delegating this to a custom api makes a lot of sense. Thanks again.

  • Anonymous
    July 27, 2015
    Hi Kirk, Good series of posts. I've followed to the letter, the one wrinkle being the WebApI controller are hosted in the same web app - I've setup a dummy application (will look at moving to seperate web app...). Also, I'm using the new Unified API (for Unified Group access/creation) Now, when I call  var clientCredential = new ClientCredential(clientID, clientSecret);                AuthenticationContext authContext = new AuthenticationContext(authority, new ADALTokenCache(signInUserId));                var authResult = authContext.AcquireToken(                   <API EndPoint>,                    clientCredential,                    new UserAssertion(userObjectId, UserIdentifierType.UniqueId.ToString())); I get the user token back fine. I pass this through to my API which when this code is called, seems to hang (assume some kind of Auth loop going on?) UserAssertion userAssertion = new UserAssertion(TokenForUser); var authResult = await authenticationContext.AcquireTokenAsync(                            ResourceUrl + "/beta",                            new ClientCredential(ClientID, ClientSecret),                            userAssertion); Any troubleshooting advice, burning precious cycles trying to get something which I had assumed would be straight forward working.