Question

I'm working with the following:

  • Azure MVC app (using organisational authentication with Azure AD)
  • WebAPI (within the MVC project)
  • SharePoint Online

MVC => API

I would like to use AJAX to communicate between the MVC page and the WebAPI rather than server postbacks. I'd also like to make the WebAPI available for other services that don't use the MVC portion of the app for future extensibility (e.g. PowerApps). As I understand it, I need to use ADAL.js to authenticate between the MVC page and the WebAPI rather than relying on passing authentication cookies in AJAX requests (as these time out frequently and this is not the recommended approach).

API => SPO

When the WebAPI receives an authenticated AJAX request from the MVC page, it should communicate with SharePoint Online - for example to update list items. Consent should be organisation-level rather than user-level.

In order to communicate with SharePoint, I need to get a ClientContext object, which I think is possible via the helper methods in OfficeDev PnP AuthenticationManager, provided I can get hold of an access token. Can I somehow retrieve an access token from the WebAPI controller context (as I have already authenticated with AzureAD via ADAL.js at this point in order to access the WebAPI) or do I need to use ADAL.NET to retrieve an access token?

Thanks!


EDIT: It looks like Kirk Evans' blog post covers this scenario - I will test and see if I can get it working

Was it helpful?

Solution

Managed to get it working - gist here: https://gist.github.com/zplume/e4244fb9415799a111e5062a23e22d1e

I updated the Azure app's manifest.json to enable oauth2 implicit flow:

"oauth2AllowImplicitFlow": true

I granted the app access to "Read and write items and lists in all site collections" on behalf of the user (under delegated permissions) from the Azure AD app settings page ("permissions to other applications").

I made sure the web.config ida:ClientId value matched the client id in Azure AD and set up a new app key in Azure AD for the app, which I copied to the web.config ida:AppKey value

   <appSettings>
    <add key="webpages:Version" value="3.0.0.0" />
    <add key="webpages:Enabled" value="false" />
    <add key="ClientValidationEnabled" value="true" />
    <add key="UnobtrusiveJavaScriptEnabled" value="true" />
    <add key="ida:ClientId" value="[app_guid]" />
    <add key="ida:AADInstance" value="https://login.microsoftonline.com/" />
    <add key="ida:Domain" value="[tenant_name].onmicrosoft.com" />
    <add key="ida:TenantId" value="[tenant_id]" />
    <add key="ida:PostLogoutRedirectUri" value="https://localhost:44300/" />
    <add key="ida:AppKey" value="[app_key]" />    
    <add key="ida:Resource" value="https://[tenant_name].sharepoint.com/" />    
</appSettings>

I retrieve a user token with ADAL.js using authenticationContext.acquireToken(clientId), then include the resulting token in the header of the AJAX request to the WebAPI:

   // Access an Azure hosted WebAPI using ADAL.js to authenticate and get user token
// requires jQuery for ajax (could be swapped out with fetch + polyfill)

// credit to the following samples/blogs:
// https://github.com/Azure-Samples/active-directory-javascript-singlepageapp-dotnet-webapi/blob/master/TodoSPA/App/Scripts/app.js
// https://blogs.msdn.microsoft.com/kaevans/2014/04/15/calling-o365-apis-from-your-web-api-on-behalf-of-a-user/
// https://www.itunity.com/article/calling-office-365-apis-jquery-adaljs-2758

(function ($) {
    var config = {
        instance: 'https://login.microsoftonline.com/',
        tenant: '[tenant_name].onmicrosoft.com',
        clientId: '[app_guid]',
        postLogoutRedirectUri: window.location.origin,
        cacheLocation: 'localStorage'
    };

    function callWebApi(token) {
        // url with trailing forward-slash removed + endpoint
        var baseUrl = window.location.href.replace(/\/$/, "");

        var url = baseUrl + "/api/action/documents?siteUrl=" + encodeURIComponent("https://[tenant_name].sharepoint.com/[site_url]");

        $.ajax({
            type: 'GET',
            url: url,
            headers: {
                'Accept': 'application/json',
                'Authorization': 'Bearer ' + token,
            }
        }).done(function (data) {
            // TODO: do something with the data
            console.log(data);
        }).fail(function () {
            // TODO: let the user know the request failed
            console.log("Error", arguments[2]);
        });
    }

    function getToken(callback) {
        var authContext = new AuthenticationContext(config);

        // save tokens if this is a return from AAD
        authContext.handleWindowCallback();

        var user = authContext.getCachedUser();
        // attempt callback
        if (user) {
            // acquire user token
            authContext.acquireToken(authContext.config.clientId, function (error, token) {
                 if (error || !token) {
                      console.log(error);
                      return;
                 }

                // execute callback function with user token as a parameter
                callback(token);
            });
        }
        else if (authContext.getLoginError()) {
            // error logging in
            console.log("Error logging in", authContext.getLoginError());
        }
        else {
            // not logged in
            console.log("Logging in");
            authContext.login();
        }
    }

    // get user token, call webapi endpoint
    getToken(callWebApi);
})(jQuery);

Then in the ApiController, I can read the user token header and include this in requests against SharePoint, either via CSOM or REST:

// make calls against SPO using a user access token passed from ADAL.js
// credit to Kirk Evans: https://blogs.msdn.microsoft.com/kaevans/2014/04/15/calling-o365-apis-from-your-web-api-on-behalf-of-a-user/

using System;
using System.Web.Http;
using System.Configuration;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using System.Globalization;
using System.Net.Http;
using System.Web;
using System.Security.Claims;
using System.Linq;

namespace zplume
{
    // routing + convention = prefix "api/action"
    [Authorize]        
    public class ActionController : ApiController
    {
        // using default routing, methods are accessible via [prefix]/[methodname], e.g. "/api/action/documents"

        [HttpGet]
        public async Task<string> Documents(string siteURL)
        {
            string accessToken = await GetAccessToken();
            return GetListTitleWithCSOM(siteURL, accessToken);

            //return await GetListTitleWithREST(siteURL, accessToken);
        }

        private static string GetListTitleWithCSOM(string siteURL, string accessToken)
        {
            var authMgr = new OfficeDevPnP.Core.AuthenticationManager();
            using (var ctx = authMgr.GetAzureADAccessTokenAuthenticatedContext(siteURL, accessToken))
            {
                var list = ctx.Web.Lists.GetByTitle("Documents");
                ctx.Load(list, l => l.Title);
                ctx.ExecuteQuery();
                return list.Title;
            }
        }

        private static async Task<string> GetListTitleWithREST(string siteURL, string accessToken)
        {
            string requestUrl = siteURL + "/_api/Web/Lists/GetByTitle('Documents')/Items?$select=Title";

            HttpClient client = new HttpClient();
            HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
            request.Headers.Add("Accept", "application/atom+xml");
            request.Headers.Add("Authorization", $"Bearer {accessToken}");
            HttpResponseMessage response = await client.SendAsync(request);

            if (response.IsSuccessStatusCode)
            {
                string responseString = await response.Content.ReadAsStringAsync();
                return responseString;
            }

            // TODO: log this value as an error, throw
            return await response.Content.ReadAsStringAsync();
        }


        private async Task<string> GetAccessToken()
        {
            string clientId = ConfigurationManager.AppSettings["ida:ClientId"];
            string appKey = ConfigurationManager.AppSettings["ida:AppKey"];
            string aadInstance = ConfigurationManager.AppSettings["ida:AADInstance"];
            string tenant = ConfigurationManager.AppSettings["ida:TenantId"];
            string domain = ConfigurationManager.AppSettings["ida:Domain"];
            string resource = ConfigurationManager.AppSettings["ida:Resource"];

            AuthenticationResult result = null;

            ClientCredential clientCred = new ClientCredential(clientId, appKey);
            string authHeader = HttpContext.Current.Request.Headers["Authorization"];                
            string userAccessToken = authHeader.Substring(authHeader.LastIndexOf(' ')).Trim();
            UserAssertion userAssertion = new UserAssertion(userAccessToken);
            string authority = aadInstance + domain;
            AuthenticationContext authContext = new AuthenticationContext(authority);

            //result = await authContext.AcquireTokenAsync(resource, clientCred); // auth without user assertion (fails, app only not allowed)

            result = await authContext.AcquireTokenAsync(resource, clientCred, userAssertion); // clientCred and userAssertion params have swapped places since Kirk's blog
            return result.AccessToken;
        }
    }
}
Licensed under: CC-BY-SA with attribution
Not affiliated with sharepoint.stackexchange
scroll top