Using OpenID Connect with SharePoint Apps

This post will show how to authenticate a user using OpenID Connect in a SharePoint provider hosted app without prompting the user for additional credentials. 

Background

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

I very often get questions about identity and authentication using SharePoint O365 apps and Azure AD in general.  One of the more popular architectural questions is how to access Exchange Online from an O365 SharePoint provider-hosted app.  In my previous post, I showed An Architecture for SharePoint Apps That Call Other Services and pointed out that this could be achieved by splitting the problem into two components: there is a SharePoint app, and an app that calls the O365 Exchange API.

image

The way to do this is to authenticate your app’s user using the same directory that they are using for SharePoint Online.  The first step is to authenticate the user using OpenID Connect.

Your SharePoint App Is Anonymous By Default

When you create a new low-trust app for SharePoint, the app does not perform any authentication.  The app is anonymous.  This is a revelation for a lot of developers, and can be confusing for IT Pros.  I create a new provider-hosted app, give it the URL of my SharePoint Online site, and click OK.  Then look at the properties for the web application, notice it is not using Windows Authentication and anonymous is enabled.

image

To prove this, I am going to change the code in the About action of the Home controller.

HomeController.About

  1. public ActionResult About()
  2. {
  3.     if(User.Identity.IsAuthenticated)
  4.     {
  5.         ViewBag.Message = "Welcome " + User.Identity.Name;                
  6.     }
  7.     else
  8.     {
  9.         ViewBag.Message = "We are Anonymous.  We are Legion.";
  10.     }
  11.         
  12.  
  13.     return View();
  14. }

If I launch the app, I can see that it identifies the user by querying SharePoint. 

image

Now click the About link in the menu where we check the User.Identity.IsAuthenticated… we are not authenticated.

image

The default page (actually the Home controller’s Index action) uses the SharePointContextFilter, while the About action does not use that filter.  Try to view the default page of the app without going to SharePoint first, and you’ll see a message that an error occurred, instructing you to launch the application from SharePoint first.

image

Yes, the app is available to anonymous users, but an anonymous user can’t do anything with the default page without the user launching the app first from SharePoint.  Our index controller action uses the SharePointContextFilter attribute to ensure there is a valid context token.  The app doesn’t authenticate the user, it only verifies that SharePoint passed a valid context token, and SharePoint won’t pass a valid context token without the user having first logged into SharePoint.

The SharePointContext code then takes care of creating an access token for me that can only be used to call SharePoint using CSOM or REST.  The token can’t be used to call other services such as the O365 Exchange Online API.  To enable access to a user’s Exchange Online information, we have to authenticate the user in order to authenticate the user and obtain a token that can be used with the O365 Exchange Online API.

Let’s show how to authenticate the users.

Authenticating Your Users

I am going to authenticate the user using OpenID Connect.  This is a protocol built on top of OAuth 2.0 that can authenticate the user and obtain an access token, and we will obtain that access token from the same Azure Active Directory that my O365 tenant is using.  We’ll do that by managing the directory using the Azure Management Portal.  Just go to the O365 administration site and click on the Admin / Azure AD link then follow the directions to manage the directory. 

image

If you have an existing Azure subscription, you can choose to manage an existing directory as well.  I show the steps how to do that in my post Calling O365 APIs from your Web API on behalf of a user

Once you have the directory open in the Azure management portal (https://manage.windowsazure.com), go to the Applications tab for your directory.  The O365 Exchange Online and O365 SharePoint Online applications are already present.  Click Add to create a new application.

image

Choose “Add an application my organization is developing”.

image

Give the app a name, such as “MyProviderHostedApp” and select “Web Application and/or Web API”.

image

On the next screen, provide the sign-in URL for your application and the app identifier.  You can find the sign-in value in your SharePoint app project’s properties in the SSL URL property.

image

The app identifier is a URI that you choose.

image

Click OK, and you now have an app registered with Azure Active Directory, and in the same O365 tenant that your SharePoint provider hosted app will communicate with.

Go to the Configure tab for your app. 

Go to the NuGet Package Manager Console (Tools / NuGet Package Manager / Package Manager Console).  Change the project to the web project:

image

Now add the following packages:

 Install-Package Microsoft.Owin.Security.OpenIdConnect
Install-Package Microsoft.Owin.Security.Cookies
Install-Package Microsoft.Owin.Host.SystemWeb

The OWIN middleware that enables OpenID Connect authentication uses HTTP cookies.  There is a problem in the implementation where cookies are not added to the HTTP response.  The workaround is described in the post .  We add a class to our project, SystemWebCookieManager.

SystemWebCookieManager

  1. using Microsoft.Owin;
  2. using Microsoft.Owin.Infrastructure;
  3. using System;
  4. using System.Web;
  5.  
  6. namespace SharePointApp5Web.Utils
  7. {
  8.     public class SystemWebCookieManager : ICookieManager
  9.     {
  10.         public string GetRequestCookie(IOwinContext context, string key)
  11.         {
  12.             if (context == null)
  13.             {
  14.                 throw new ArgumentNullException("context");
  15.             }
  16.  
  17.             var webContext = context.Get<HttpContextBase>(typeof(HttpContextBase).FullName);
  18.             var cookie = webContext.Request.Cookies[key];
  19.             return cookie == null ? null : cookie.Value;
  20.         }
  21.  
  22.         public void AppendResponseCookie(IOwinContext context, string key, string value, CookieOptions options)
  23.         {
  24.             if (context == null)
  25.             {
  26.                 throw new ArgumentNullException("context");
  27.             }
  28.             if (options == null)
  29.             {
  30.                 throw new ArgumentNullException("options");
  31.             }
  32.  
  33.             var webContext = context.Get<HttpContextBase>(typeof(HttpContextBase).FullName);
  34.  
  35.             bool domainHasValue = !string.IsNullOrEmpty(options.Domain);
  36.             bool pathHasValue = !string.IsNullOrEmpty(options.Path);
  37.             bool expiresHasValue = options.Expires.HasValue;
  38.  
  39.             var cookie = new HttpCookie(key, value);
  40.             if (domainHasValue)
  41.             {
  42.                 cookie.Domain = options.Domain;
  43.             }
  44.             if (pathHasValue)
  45.             {
  46.                 cookie.Path = options.Path;
  47.             }
  48.             if (expiresHasValue)
  49.             {
  50.                 cookie.Expires = options.Expires.Value;
  51.             }
  52.             if (options.Secure)
  53.             {
  54.                 cookie.Secure = true;
  55.             }
  56.             if (options.HttpOnly)
  57.             {
  58.                 cookie.HttpOnly = true;
  59.             }
  60.  
  61.             webContext.Response.AppendCookie(cookie);
  62.         }
  63.  
  64.         public void DeleteCookie(IOwinContext context, string key, CookieOptions options)
  65.         {
  66.             if (context == null)
  67.             {
  68.                 throw new ArgumentNullException("context");
  69.             }
  70.             if (options == null)
  71.             {
  72.                 throw new ArgumentNullException("options");
  73.             }
  74.  
  75.             AppendResponseCookie(
  76.                 context,
  77.                 key,
  78.                 string.Empty,
  79.                 new CookieOptions
  80.                 {
  81.                     Path = options.Path,
  82.                     Domain = options.Domain,
  83.                     Expires = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc),
  84.                 });
  85.         }
  86.     }
  87.  
  88. }

Now that we have the ability to preserve the cookies in the response, we can add the OWIN middleware to handle OpenID Connect authentication.  Right-click the web project and choose Add / New Item and choose OWIN Startup Class.  Name it Startup.cs.

image

To be consistent with the rest of Visual Studio’s tooling, we are going to change this to a partial class that calls a method “ConfigureAuth” that we’ll code in a moment.

Startup.cs

  1. using System;
  2. using System.Threading.Tasks;
  3. using Microsoft.Owin;
  4. using Owin;
  5.  
  6. [assembly: OwinStartup(typeof(SharePointApp5Web.Startup))]
  7.  
  8. namespace SharePointApp5Web
  9. {
  10.     public partial class Startup
  11.     {
  12.         public void Configuration(IAppBuilder app)
  13.         {
  14.             ConfigureAuth(app);
  15.         }
  16.     }
  17. }

Go to the App_Start folder for your web application and add a new class named Startup.Auth.cs.

image

This is going to be a partial class, so change the namespace to match the namespace of your Startup.cs class, add some using statements, and add the shell of the ConfigureAuth method.

Startup.Auth.cs

  1. using Microsoft.Owin.Security;
  2. using Microsoft.Owin.Security.Cookies;
  3. using Microsoft.Owin.Security.OpenIdConnect;
  4. using Owin;
  5.  
  6. namespace SharePointApp5Web
  7. {
  8.     public partial class Startup
  9.     {
  10.         public void ConfigureAuth(IAppBuilder app)
  11.         {
  12.  
  13.         }
  14.     }
  15. }

The ConfigureAuth code is where the OWIN middleware will handle the OpenID Connect authentication for the ASP.NET MVC web application. 

ConfigureAuth

  1. public void ConfigureAuth(IAppBuilder app)
  2.         {
  3.             app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
  4.  
  5.             app.UseCookieAuthentication(new CookieAuthenticationOptions
  6.                 {
  7.                     CookieManager = new SystemWebCookieManager()
  8.                 });
  9.  
  10.             string clientID = ConfigurationManager.AppSettings["ida:ClientID"];
  11.             string aadInstance = ConfigurationManager.AppSettings["ida:AADInstance"];
  12.             string tenant = ConfigurationManager.AppSettings["ida:Tenant"];
  13.             string authority = string.Format(CultureInfo.InvariantCulture, aadInstance, tenant);
  14.  
  15.             app.UseOpenIdConnectAuthentication(
  16.                 new OpenIdConnectAuthenticationOptions
  17.                 {
  18.                     ClientId = clientID,
  19.                     Authority = authority
  20.                 });
  21.         }

The code reads settings from the Web.Config file.  Notice that we still have the ClientId and ClientSecret that the SharePoint app uses, but that is separate from the ClientID that identifies our app to Azure AD.

Web.config appSettings

  1. <!-- SharePoint apps OAuth-->

  2. <add key="ClientId" value="90e6cd36-fd8b-4eda-8123-506398fdf5bc" />

  3. <add key="ClientSecret" value="nUOcOdsalkjsdfglkjsaweoip5ZFhLZMgUw=" />

  4. <!-- Azure AD OAuth -->

  5. <add key="ida:ClientID" value="77d50962-3a4d-461e-976b-cacad345a11c" />

  6. <add key="ida:AADInstance" value="https://login.windows.net/{0}" />

  7. <add key="ida:Tenant" value="kirke3.onmicrosoft.com" />

Change the ida:ClientID value to the Client ID of your app (found in the Azure Management portal on the Configure tab for the application you registered previously).  Change the “ida:Tenant” to match the name of your tenant.

image

The ida:AADInstance configuration indicates which instance of Azure you are using.  If you are a user in China, the AAD instance would be different.

The last bit of magic is to add the Authorize attribute to the HomeController’s About action.  This ensures that only authenticated users can access the About method of the HomeController class.

image

You can add the Authorize attribute to the class instead of a single action, ensuring that anybody accessing the app is authenticated.  We add it only to the About method here.

Testing… Testing… Is this thing on?

First, let’s test that authentication now works.  Hit F5 to run the app.  I am asked to log in to O365 in order to access my app.

image

I am then redirected to my app… and of course no further logon occurs because we haven’t accessed the About action yet. 

image

Now go to the About tab.  A redirect occurs, but the user is not prompted a second time for credentials.  Let me point this out again, because this is also a popular point of confusion:  THERE IS NO ADDITIONAL PROMPT FOR THE USER TO AUTHENTICATE. 

image

If we watch the traffic with Fiddler when that redirect occurred, we can see that it was actually handling the authentication for us.  The presence of the cookie from the O365 logon is why we didn’t see an additional logon prompt.

image

Let’s test what it looks like when we don’t launch from SharePoint first, such as when the user bookmarks a URL to the app.  Go to the About link and copy its URL, including all the SPHostUrl stuff in the querystring. 

image

In Internet Explorer, start an In Private browsing session (which starts a new instance and clears existing cookies).  When the browser launches, this time you are prompted to sign into Azure Active Directory, not O365.

image

Log in, and you are redirected to the app.  If you watch carefully, you were redirected back to O365 to obtain a valid SharePoint context token, but the user was not prompted for an additional logon

image

Now click the Home link in the menu.  This will execute the SharePointContextFilter code, which will see the SPHostUrl in the querystring without a valid context token.  It then redirects to SharePoint, which responds with a valid context token that can now be used for SharePoint provider-hosted apps.

image

Caution: During testing, I observed some very odd behavior when signing into Azure AD first… the HTTP cookie was being dropped and I got into an endless loop of redirects.  The solution to this is the SystemWebCookieManager that we detailed earlier in this post. 

Summary

Using OpenID Connect in this manner opens a huge amount of possibility for our application.  Our users are now authenticated, enabling the app to do more than simply interact with SharePoint.  We can now use our app to take advantage of the rich ecosystem exposed by Azure Active Directory.  In a future post, we’ll show how we can take advantage of the OWIN middleware to provide a custom WebAPI protected by Azure AD bearer authentication that enables access to endpoints such as the O365 Exchange Online API, O365 SharePoint Online API, Azure AD Graph API, and PowerBI API.

For More Information

An Architecture for SharePoint Apps That Call Other Services

Calling O365 APIs from your Web API on behalf of a user

Problem with cookies in Microsoft.Owin.Host.SystemWeb – details the SystemWebCookieManager workaround