Call Azure AD secured API from your SPFx code. Story #2: Web app (or Azure Function) and SPFx with adal.js

Call Azure AD secured API from your SPFx code series:

  1. Call Azure AD secured API from your SPFx code. Story #1: Azure Functions with cookie authentication (xhr "with credentials")
  2. Call Azure AD secured API from your SPFx code. Story #1.1: Azure Web App with ASP.NET Core 2.x and cookie authentication (xhr "with credentials")
  3. Call Azure AD secured API from your SPFx code. Story #2: Web app (or Azure Function) and SPFx with adal.js  <—you are here
  4. Call Azure AD secured API from your SPFx code. Story #3: Web app (or Azure Function) and SPFx with AadHttpClient

It’s possible to call your remote Azure AD secured API with help of popular adal.js library. This approach has a number of issues (read in the end of the post). Almost all issues come from a fact, that adal.js works well in case of SPA and doesn’t play nicely in SPFx world. To make it work with SPFx, you should “patch” it. Even in this case there are some caveats. That’s why for now recommended approach is using AadHttpClient, however for the sake of completeness I wrote a post on adal.js as well. By the way, AadHttpClient is still in preview (as of now, check the actual state at docs.microsoft.com).

Read more on this topic here – Connect to API secured with Azure Active Directory and here – Call the Microsoft Graph API using OAuth from your web part.

In today’s post we need to perform below steps:

  1. Add new app registration in Azure AD
  2. Create Azure AD secured API (Web App with custom jwt bearer authentication or Azure Function with EasyAuth aka App Service Authentication, I will cover both) and enable CORS
  3. Patch adal.js library to work with SPFx
  4. Create SPFx web part, which uses adal.js and calls remote Azure AD protected API

The source code for this article available on GitHub here.

Let’s get started

1. Add new app registration in Azure AD

In Azure Portal, click Azure Active Directory –> App Registration. Create new registration using a Web app option and give any name for the app. Go to app Reply URLs and put callback url to your web app. We use locally running web app for testing, that's why you can put https://localhost:44361/  (or any url where your web app runs locally):

Copy ApplicationID from the app properties. We need it in the next step. You should also copy your TenantId. In Azure Portal, you can click on  Azure Active Directory once again and select Properties. Copy Directory Id.

2. Create Azure AD secured API (Web App with custom jwt bearer authentication or Azure Function with EasyAuth aka App Service Authentication)

I use active-directory-dotnet-webapp-openidconnect-aspnetcore sample as starting point for my web api. After cloning the repository, open project in Visual Studio and update values in the appsettings.json file according to your tenant. Basically, you need to update Domain, TenantId, ClientId.

Open project properties and make sure that under Debug you have SSL enabled and Anonymous Authentication enabled as well:

At this point we have ASP.NET application, however we need to add JWT Bearer authentication support and configure CORS as well (as we are going to send cross-domain AJAX requests from SPFx).

You should update your Startup.cs with below code:

// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
	services.AddAuthentication(sharedOptions =>
	{
		sharedOptions.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
	})
	.AddJwtBearer(options =>
	{
		options.Audience = Configuration.GetSection("AzureAd")["ClientId"];
		options.Authority = $"{Configuration.GetSection("AzureAd")["Instance"]}{Configuration.GetSection("AzureAd")["TenantId"]}";
	});

	services.AddCors(options =>
	{
		options.AddPolicy("AllOrigins",
			builder =>
			{
				builder
					.AllowAnyOrigin()
					.AllowAnyMethod()
					.AllowAnyHeader();
			});
	});

	services.AddMvc();
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
	if (env.IsDevelopment())
	{
		app.UseDeveloperExceptionPage();
	}
	else
	{
		app.UseExceptionHandler("/Home/Error");
	}

	app.UseStaticFiles();

	app.UseAuthentication();

	app.UseCors("AllOrigins");

	app.UseMvc(routes =>
	{
		routes.MapRoute(
			name: "default",
			template: "{controller=Home}/{action=Index}/{id?}");
	});
}

What we’ve done – we’ve added jwt authentication (AddJwtBearer) to our api and added CORS support (AddCors). Under Controllers folder you can delete everything and add new controller called ClientsController.cs:

[Authorize]
[Route("api/[controller]")]
public class ClientsController : Controller
{
	private readonly ILogger _logger;

	public ClientsController(ILogger<ClientsController> logger)
	{
		_logger = logger;
	}

	[HttpGet]
	public dynamic Index()
	{
		var clients = new List<dynamic>
		{
			new
			{
				Name = "Tim Perez"
			},
			new
			{
				Name = "John Smith"
			}
		};

		return clients;
	}

	[HttpPost]
	public object PostData(object data)
	{
		_logger.LogInformation("Post data received!");

		return new
		{
			message = "data was successfully posted!"
		};
	}
}

If you run the app and try to send GET HTTP request to https://localhost:44361/api/clients, you will receive 401 Unauthorized exception, because you should attach authorization header to make it work. This is something we are going to do in SPFx web part with help of adal.js.

You also can use Azure Easy auth knowns also as App Service Authentication. Instead of configuring everything in code, you can do that in Azure Portal. Refer to this post, 4. Setup Azure AD authentication for Function App. However for CORS option you should add workbench url and your SharePoint site url where you are going to host your app.

3. Patch adal.js library to work with SPFx

As said, adal.js plays perfectly with single page application, thus it doesn’t work well with SPFx web parts. We should patch it. Please note, that you should patch 1.0.12 version of adal.js, other versions might not work. Basically what you need to do, is to place below code in start point of your SPFx web part (WebPartAuthenticationContext.js):

const AuthenticationContext = require('adal-angular');

AuthenticationContext.prototype._getItemSuper = AuthenticationContext.prototype._getItem;
AuthenticationContext.prototype._saveItemSuper = AuthenticationContext.prototype._saveItem;
AuthenticationContext.prototype.handleWindowCallbackSuper = AuthenticationContext.prototype.handleWindowCallback;
AuthenticationContext.prototype._renewTokenSuper = AuthenticationContext.prototype._renewToken;
AuthenticationContext.prototype.getRequestInfoSuper = AuthenticationContext.prototype.getRequestInfo;
AuthenticationContext.prototype._addAdalFrameSuper = AuthenticationContext.prototype._addAdalFrame;

AuthenticationContext.prototype._getItem = function (key) {
  if (this.config.webPartId) {
    key = this.config.webPartId + '_' + key;
  }

  return this._getItemSuper(key);
};

AuthenticationContext.prototype._saveItem = function (key, object) {
  if (this.config.webPartId) {
    key = this.config.webPartId + '_' + key;
  }

  return this._saveItemSuper(key, object);
};

AuthenticationContext.prototype.handleWindowCallback = function (hash) {
  if (hash == null) {
    hash = window.location.hash;
  }

  if (!this.isCallback(hash)) {
    return;
  }

  var requestInfo = this.getRequestInfo(hash);
  if (requestInfo.requestType === this.REQUEST_TYPE.LOGIN) {
    return this.handleWindowCallbackSuper(hash);
  }

  var resource = this._getResourceFromState(requestInfo.stateResponse);
  if (!resource || resource.length === 0) {
    return;
  }

  if (this._getItem(this.CONSTANTS.STORAGE.RENEW_STATUS + resource) === this.CONSTANTS.TOKEN_RENEW_STATUS_IN_PROGRESS) {
    return this.handleWindowCallbackSuper(hash);
  }
}

AuthenticationContext.prototype._renewToken = function (resource, callback) {
  this._renewTokenSuper(resource, callback);
  var _renewStates = this._getItem('renewStates');
  if (_renewStates) {
    _renewStates = _renewStates.split(',');
  }
  else {
    _renewStates = [];
  }
  _renewStates.push(this.config.state);
  this._saveItem('renewStates', _renewStates);
}

AuthenticationContext.prototype.getRequestInfo = function (hash) {
  var requestInfo = this.getRequestInfoSuper(hash);
  var _renewStates = this._getItem('renewStates');
  if (!_renewStates) {
    return requestInfo;
  }

  _renewStates = _renewStates.split(';');
  for (var i = 0; i < _renewStates.length; i++) {
    if (_renewStates[i] === requestInfo.stateResponse) {
      requestInfo.requestType = this.REQUEST_TYPE.RENEW_TOKEN;
      requestInfo.stateMatch = true;
      break;
    }
  }

  return requestInfo;
}

AuthenticationContext.prototype._addAdalFrame = function (iframeId) {
  var adalFrame = this._addAdalFrameSuper(iframeId);
  adalFrame.style.width = adalFrame.style.height = '106px';
  return adalFrame;
}

window.AuthenticationContext = function () {
  return undefined;
}

Looks scared? Smile Basically these changes needed to store adal’s data per web part basis, instead of globally. Probably it’s good idea to publish separate version with SPFx support, however as said this approach doesn’t work very well in SPFx world, that’s the reason why we don’t have separate version.

4. Create SPFx web part, which uses adal.js and calls remote Azure AD protected API

Create SPFx web part with “No Javascript framework”. Add patched WebPartAuthenticationContext.js to your webparts folder and in HelloWorldWebPart.ts add import (I’ve also added jquery stuff for easier dom manipulations):

import * as AuthenticationContext from 'adal-angular';
import '../WebPartAuthenticationContext';
import * as $ from 'jquery';

In the same file add interface, which extends adal’s config interface:

interface IAdalConfig extends AuthenticationContext.Options {
  webPartId?: string;
}

Change render method:

public render(): void {
    let apiUrl = 'https://localhost:44361/';

    const config: IAdalConfig = {
      webPartId: this.context.instanceId,
      clientId: '6fc2655e-04cd-437d-a50d-0c1a31383775',
      popUp: true,
      instance: 'https://login.microsoftonline.com/',
      tenant: '948fd9cc-9adc-40d8-851e-acefa17ab66c'
    }

    this.domElement.innerHTML = `
      <div class="${ styles.helloWorld}">
        <button id='btn-adal'>Get data from API</button>
      </div>`;

    $('#btn-adal').on('click', () => {
      let authCtx = new AuthenticationContext(config);
      (AuthenticationContext as any).prototype._singletonInstance = undefined;
      authCtx.login();

      let interval = setInterval(() => {
        if (authCtx.loginInProgress()) return;

        clearInterval(interval);

        authCtx.acquireToken(config.clientId, (error: string, token: string) => {
          if(error){
            console.error(error);
            throw error;
          }

          console.log(token);

          this.context.httpClient.fetch(`${apiUrl}api/clients`,
            HttpClient.configurations.v1, {
              method: "GET",
              headers: {
                'Authorization': `Bearer ${token}`
              }
            })
            .then((response: HttpClientResponse): Promise<any[]> => {
              if (response.ok) {
                return response.json();
              } else {
                return Promise.resolve(null);
              }
            })
            .then((data: any[]): void => { 
              console.log(data);
            });
        })
      }, 100);
    });
  }

A few words about the code. Firstly, we create adal’s config, which holds essential data for our api – clientId, tenant. It also holds SPFx specific things like webpartId. We use popup instead of full page redirects.

By clicking on the button, you will be logged in by adal.js (using popup window), then an access token for your API will be generated. We attach those token to ongoing HTTP request via Authorization header and we are good to go. Run gulp serve, then click on a button and you will see your API data in console.

Conclusion

This approach isn’t recommended until you don’t have any other ways to connect to your API. Recommended way is to use AadHttpClient. Which caveats we have with described approach:

  • adal.js uses iframes to generate tokens, with iframes we might have IE security zone issues
  • this approach uses adal-per-webpart. It means, that multiple web parts on the same page will trigger popups
  • you should enable popups in your browser
  • it uses patched version of adal.js limited to 1.0.12 version only
  • you should provide reply url in azure app registration to match your web part host page url. For now only 10 urls can be provided

This is it. The next post will be about Remote API and AadHttpClient.