Building Proxy Provider for SharePoint Framework and Microsoft Graph Toolkit

The Microsoft Graph Toolkit (MGT) is a collection of reusable, framework-agnostic components and authentication providers for accessing and working with Microsoft Graph. The components are fully functional right out of the box, with built in providers that authenticate with and fetch data from Microsoft Graph.

MGT has a nice and simple integration with SharePoint Framework with just one line of code:

Providers.globalProvider = new SharePointProvider(this.context);

However, MGT supports other provider types as well. Like Proxy provider. In some cases, it makes sense to use the Proxy provider inside your SharePoint Framework solution instead of the SharePointProvider. Here is why:

  • you have a backend API that talks to MS Graph with on-behalf-of (OBO) flow. You don't use JS code to talk to MS Graph and rely solely on the backend
  • you don't want to maintain many "webApiPermissionRequests" entries inside package-solution.json, because every new permission requires re-deployment

If the above is true for you, then you can implement the Proxy provider for MGT instead of SharePointProvider.

The source code for this sample is available at GitHub here.

Solution overview

This is the approximate schema of how it works:

Explanation:

MGT is configured to use a Proxy provider with the URL to our backend. The backend uses Azure AD for authentication and OBO flow to request access tokens for MS Graph in the proxy controller. As an example, the web part uses the <Person> MGT component. In the beginning, we need an access token for our backend, thus we use aadTokenProvider SPFx API to get an access token and init Proxy provider.

Proxy provider, which is running on SPFx, uses aadTokenProvider and generates an access token for our backend API. Then Proxy provider sends authenticated requests to the backend with the access token. Proxy backend controller exchanges tokens using OBO flow and then passes requests further to MS Graph API.

MS Graph returns data back, proxy backend controller, in turn, returns data to <Person> MGT control on the SPFx web part. <Person> updates itself based on the received data. 

App registration

We'll start with the app registration. 

  1. In your Azure Add under App Registrations click "New Registration".
  2. Provide a name, click "Register".
  3. Under Authentication enable "Access tokens" and "ID tokens" checkboxes.
  4. Under "Certificates and secrets" generate a new Client Secret and save it (we need it later).
  5. Under "API Permissions" add all permissions needed for your app. In my sample, I use Person MGT control, which needs only User.Read. If you need more, like Calendars, Mails, etc, then simply add all required. 
  6. Grant admin consent for all permissions on the same page.
  7. Under "Expose an API" generate an App ID URI, i.e. api://<api name>/<client id>, for example api://spfx-mgt-proxy/14bfb200-fe7b-44ac-b19f-08d9fc2f833e
  8. Click on "Add scope", give it a name "user_impersonation", enabled for admins and users, display name "Access the API"
  9. Save all changes. 

SharePoint Framework web part

Scaffold a new SPFx webpart, add MGT dependencies: 

npm install mgt-element mgt-proxy-provider mgt-react --save

In the default web part code add init method:

public onInit(): Promise<void> {
Providers.globalProvider = new ProxyProvider("https://localhost:44320/api/GraphProxy", async () => {
  const provider = await this.context.aadTokenProviderFactory.getTokenProvider();
  const token = await provider.getToken(CLIENT_ID);
  return {
	Authorization: `Bearer ${token}`,
  };
});

return Promise.resolve();
}

Where Client_Id is your app registration's ClientId (Application Id), https://localhost:44320/api/GraphProxy is our backend proxy URL (will be configured later). In the code above we use the built-in SPFx mechanism for obtaining an access token for our backend API, which serves as a proxy for MGT controls. 

The component itself is quite simple:

import * as React from 'react';
import { FC } from 'react';
import { Person, PersonViewType, PersonCardInteraction } from '@microsoft/mgt-react';

const MgtProxy: FC = () => {
  return (
    <div>
      <Person personQuery="me" view={PersonViewType.twolines} personCardInteraction={PersonCardInteraction.hover} />
    </div>
  )
}

export default MgtProxy;

We should also update package-solution.json to include information about our backend API. Add new webApiPermissionRequests entry:

"webApiPermissionRequests": [
 {
   "resource": "SPFx-mgt-proxy",
   "scope": "user_impersonation"
 } 
]

Package solution:

gulp bundle --ship
gulp package-solution --ship

Upload .sppkg file to the App Catalog and approve permission request from Central Admin (from API Management page). 

ASP.NET 5 backend proxy

Our backend is just a regular ASP.NET 5 project configured with Azure AD authentication and a proxy Web API controller inside. 

You can easily convert it to the ASP.NET 6 because for this project it's almost identical setup.

At the beginning update appsettings.json - under AzureAd section update ClientId, ClientSecret and Audience (your client id) values.

Here is how you should configure services:

public void ConfigureServices(IServiceCollection services)
{
	services.AddMicrosoftIdentityWebApiAuthentication(Configuration)
	.EnableTokenAcquisitionToCallDownstreamApi()
	.AddInMemoryTokenCaches();

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

	services.AddScoped<MSGraphClientFactory>();

	services.AddScoped((provider) =>
	{
		var factory = provider.GetRequiredService<MSGraphClientFactory>();
		return factory.CreateGraphClient();
	});

	services.AddControllers();
}

In my setup I allow CORS from any hosts, but for your solution you may implement more strict conditions to improve security.

AddMicrosoftIdentityWebApiAuthentication protects our API with Azure AD authentication. By default, it reads the config section with the name "AzureAd", which we configured before. 

EnableTokenAcquisitionToCallDownstreamApi adds support for on-behalf-of (OBO) flow for MS Graph and other services. Under the hood, it uses the MSAL.NET library to perform necessary Azure AD calls. 

AddInMemoryTokenCaches uses memory to store tokens so that we don't call Azure AD every time we need a token.

What is MSGraphClientFactory then? That's a class, which creates an instance of GraphServiceClient

public class MSGraphClientFactory
{
	private readonly ITokenAcquisition _tokenAcquisition;
	// put all needed for your solution MS Graph scopes here
	private readonly string[] _scopes = new[] { "User.Read" };

	public MSGraphClientFactory(ITokenAcquisition tokenAcquisition)
	{
		_tokenAcquisition = tokenAcquisition;
	}

	public GraphServiceClient CreateGraphClient()
	{
		return new GraphServiceClient(
			new DelegateAuthenticationProvider(
				async (requestMessage) =>
				{
					var result = await _tokenAcquisition.GetAccessTokenForUserAsync(_scopes);
					requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", result);
				}));
	}
}

IMPORTANT. I use only User.Read scope, because I use <Person> component only. If you need more, then add all needed scopes in this class. Don't forget to update app registration and grant permissions for the tenant afterwards.

_tokenAcquisition.GetAccessTokenForUserAsync is an OBO call (as said backed with MSAL.NET). 

This is the proxy Controller:

spoiler (click to show)
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class GraphProxyController : ControllerBase
{
	private readonly GraphServiceClient _graphServiceClient;

	public GraphProxyController(GraphServiceClient graphServiceClient)
	{
		_graphServiceClient = graphServiceClient;
	}

	[HttpGet]
	[Route("{*all}")]
	public async Task<IActionResult> GetAsync(string all)
	{
		return await ProcessRequestAsync("GET", all, null).ConfigureAwait(false);
	}

	[HttpPost]
	[Route("{*all}")]
	public async Task<IActionResult> PostAsync(string all, [FromBody] object body)
	{
		return await ProcessRequestAsync("POST", all, body).ConfigureAwait(false);
	}

	[HttpDelete]
	[Route("{*all}")]
	public async Task<IActionResult> DeleteAsync(string all)
	{
		return await ProcessRequestAsync("DELETE", all, null).ConfigureAwait(false);
	}

	[HttpPut]
	[Route("{*all}")]
	public async Task<IActionResult> PutAsync(string all, [FromBody] object body)
	{
		return await ProcessRequestAsync("PUT", all, body).ConfigureAwait(false);
	}

	[HttpPatch]
	[Route("{*all}")]
	public async Task<IActionResult> PatchAsync(string all, [FromBody] object body)
	{
		return await ProcessRequestAsync("PATCH", all, body).ConfigureAwait(false);
	}

	private async Task<IActionResult> ProcessRequestAsync(string method, string all, object content)
	{
		var qs = HttpContext.Request.QueryString;
		var url = $"{GetBaseUrlWithoutVersion(_graphServiceClient)}/{all}{qs.ToUriComponent()}";

		var request = new BaseRequest(url, _graphServiceClient, null)
		{
			Method = method,
			ContentType = HttpContext.Request.ContentType,
		};

		var neededHeaders = Request.Headers.Where(h => h.Key.ToLower() == "if-match" || h.Key.ToLower() == "consistencylevel").ToList();
		if (neededHeaders.Count() > 0)
		{
			foreach (var header in neededHeaders)
			{
				request.Headers.Add(new HeaderOption(header.Key, string.Join(",", header.Value)));
			}
		}

		var contentType = "application/json";

		try
		{
			using (var response = await request.SendRequestAsync(content?.ToString(), CancellationToken.None, HttpCompletionOption.ResponseContentRead).ConfigureAwait(false))
			{
				response.Content.Headers.TryGetValues("content-type", out var contentTypes);

				contentType = contentTypes?.FirstOrDefault() ?? contentType;

				var byteArrayContent = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
				return new HttpResponseMessageResult(ReturnHttpResponseMessage(HttpStatusCode.OK, contentType, new ByteArrayContent(byteArrayContent)));
			}
		}
		catch (ServiceException ex)
		{
			return new HttpResponseMessageResult(ReturnHttpResponseMessage(ex.StatusCode, contentType, new StringContent(ex.Error.ToString())));
		}
	}

	private static HttpResponseMessage ReturnHttpResponseMessage(HttpStatusCode httpStatusCode, string contentType, HttpContent httpContent)
	{
		var httpResponseMessage = new HttpResponseMessage(httpStatusCode)
		{
			Content = httpContent
		};

		try
		{
			httpResponseMessage.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
		}
		catch
		{
			httpResponseMessage.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
		}

		return httpResponseMessage;
	}

	private string GetBaseUrlWithoutVersion(GraphServiceClient graphClient)
	{
		var baseUrl = graphClient.BaseUrl;
		var index = baseUrl.LastIndexOf('/');
		return baseUrl.Substring(0, index);
	}

	public class HttpResponseMessageResult : IActionResult
	{
		private readonly HttpResponseMessage _responseMessage;

		public HttpResponseMessageResult(HttpResponseMessage responseMessage)
		{
			_responseMessage = responseMessage; // could add throw if null
		}

		public async Task ExecuteResultAsync(ActionContext context)
		{
			context.HttpContext.Response.StatusCode = (int)_responseMessage.StatusCode;

			foreach (var header in _responseMessage.Headers)
			{
				context.HttpContext.Response.Headers.TryAdd(header.Key, new StringValues(header.Value.ToArray()));
			}

			context.HttpContext.Response.ContentType = _responseMessage.Content.Headers.ContentType.ToString();

			using (var stream = await _responseMessage.Content.ReadAsStreamAsync())
			{
				await stream.CopyToAsync(context.HttpContext.Response.Body);
				await context.HttpContext.Response.Body.FlushAsync();
			}
		}
	}

}

Most of the code was taken from the MGT sample here.

Run it

To run, hit F5 in Visual Studio and "npm run serve" inside the SPFx solution. Use hosted workbench, it should display currently logged in user info:

Title image attribution - People vector created by pch.vector - www.freepik.com