In this article, we will go through how to call an Azure AD protected API as the calling user from another Azure AD protected API.
That is a fairly long sentence, so let's look at an example scenario where this is used:
- A JavaScript Single Page Application authenticates the user with Azure AD
- The SPA gets an access token for its back-end API and calls the API
- The API then needs to get information about the user's manager from Microsoft Graph API
In this scenario, there are basically two options:
- Use the on-behalf-of grant to acquire an access token that allows the API to call MS Graph as the user
- Use client credentials grant to make the call as the API, with no user context
The first option uses delegated permissions, which mean the data that can be returned is based on what the API and user are allowed to access. It does require the call made to this API was made with a user context.
The second option would instead use application permissions, in which case the app itself would need to have access to this information for any user in the organisation.
You can probably understand why using delegated permissions is usually preferred. It follows the principle of least privilege.
You can find the sample app used in this article at https://github.com/juunas11/azure-ad-on-behalf-of-sample-aspnetcore.
Overview
From a helicopter point-of-view the flow goes like this:
- API receives call including access token
- API calls Azure AD's token endpoint including the following things:
- The access token it got
- The resource it wants to access
- Its client id and secret
- Azure AD gives the API an access token
So basically we are exchanging the access token the API got for another access token.
Let's see how an ASP.NET Core 2.0 API using this flow might look like!
Startup configuration
We will need to configure JWT Bearer authentication as usual in the API. In addition, we must make sure that the token used to call this API is saved in-memory on the request.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(o =>
{
o.Filters.Add(new AuthorizeFilter("default"));
});
services.AddAuthorization(o =>
{
o.AddPolicy("default", builder =>
{
builder
.RequireAuthenticatedUser()
.RequireClaim(AzureAdClaimTypes.Scope, "user_impersonation");
//Require additional claims, setup other policies etc.
});
});
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
AuthenticationOptions authSettings = Configuration.GetSection("Authentication").Get<AuthenticationOptions>();
//Identify the identity provider
o.Authority = authSettings.Authority;
//Require tokens be saved in the AuthenticationProperties on the request
//We need the token later to get another token
o.SaveToken = true;
o.TokenValidationParameters = new TokenValidationParameters
{
//Both the client id and app id URI of this API should be valid audiences
ValidAudiences = new List<string> { authSettings.ClientId, authSettings.AppIdUri }
};
});
services.Configure<AuthenticationOptions>(Configuration.GetSection("Authentication"));
services.AddSingleton<IGraphApiService, GraphApiService>();
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddSingleton<IAuthenticationProvider, OnBehalfOfMsGraphAuthenticationProvider>();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseAuthentication();
app.UseMvc();
}
First note that we set:
o.SaveToken = true;
This will cause the authentication handler to store the access token used to call this API in the AuthenticationProperties
for the request.
It will allow us to easily get it later.
An important thing to also note is that we do not get the access token here.
Since we might not need it on every request, it would be a waste of time.
Acquiring a token using the On-Behalf-Of grant flow
In a service layer, we need an access token for the Microsoft Graph API for acting on behalf of the calling user.
It is the exact reason the On-Behalf-Of grant type exists. It allows us to exchange this APIs credentials + the access token used to call it for another access token.
In this sample app, we are using the Microsoft Graph API library.
It requires us to give it an instance of an IAuthenticationProvider
.
Here we have implemented this provider in a separate class that can acquire the access token and attach it to the HTTP request before it is sent.
public class OnBehalfOfMsGraphAuthenticationProvider : IAuthenticationProvider
{
private readonly IDistributedCache _distributedCache;
private readonly ILoggerFactory _loggerFactory;
private readonly IDataProtectionProvider _dataProtectionProvider;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly Options.AuthenticationOptions _authSettings;
public OnBehalfOfMsGraphAuthenticationProvider(
IDistributedCache distributedCache,
ILoggerFactory loggerFactory,
IDataProtectionProvider dataProtectionProvider,
IOptions<Options.AuthenticationOptions> authenticationOptions,
IHttpContextAccessor httpContextAccessor)
{
_distributedCache = distributedCache;
_loggerFactory = loggerFactory;
_dataProtectionProvider = dataProtectionProvider;
_httpContextAccessor = httpContextAccessor;
_authSettings = authenticationOptions.Value;
}
public async Task AuthenticateRequestAsync(HttpRequestMessage request)
{
var httpContext = _httpContextAccessor.HttpContext;
//Get the access token used to call this API
string token = await httpContext.GetTokenAsync("access_token");
//We are passing an *assertion* to Azure AD about the current user
//Here we specify that assertion's type, that is a JWT Bearer token
string assertionType = "urn:ietf:params:oauth:grant-type:jwt-bearer";
//User name is needed here only for ADAL, it is not passed to AAD
//ADAL uses it to find a token in the cache if available
var user = httpContext.User;
string userName = user.FindFirstValue(ClaimTypes.Upn) ?? user.FindFirstValue(ClaimTypes.Email);
var userAssertion = new UserAssertion(token, assertionType, userName);
//Construct the token cache
var cache = new DistributedTokenCache(user, _distributedCache, _loggerFactory, _dataProtectionProvider);
var authContext = new AuthenticationContext(_authSettings.Authority, cache);
var clientCredential = new ClientCredential(_authSettings.ClientId, _authSettings.ClientSecret);
//Acquire access token
var result = await authContext.AcquireTokenAsync("https://graph.microsoft.com", clientCredential, userAssertion);
//Set the authentication header
request.Headers.Authorization = new AuthenticationHeaderValue(result.AccessTokenType, result.AccessToken);
}
}
Let's go through the request authentication line-by-line so you can understand what is going on.
First we get the HTTP context for the current request via the IHttpContextAccessor
.
Since this authentication provider is registered as a singleton, we must get the context here when the function is called, not in the constructor.
var httpContext = _httpContextAccessor.HttpContext;
Then we get the access token for this request that was saved in AuthenticationProperties
by the JwtBearerHandler
by turning on SaveToken
.
string token = await httpContext.GetTokenAsync("access_token");
Then we declare the type of the assertion we use for asserting the current user, in this case a JSON Web Token (JWT).
string assertionType = "urn:ietf:params:oauth:grant-type:jwt-bearer";
Then we need some claims from the user that was identified by the access token.
So we get it from the HttpContext
.
var user = httpContext.User;
We need some identifier for the current user. So we try to get a User Principal Name claim first, and if that is not available, their email address.
string userName = user.FindFirstValue(ClaimTypes.Upn) ?? user.FindFirstValue(ClaimTypes.Email);
Then we create our assertion. It basically says:
- This is our current user: token + userName
- The user is identified by this Bearer token
var userAssertion = new UserAssertion(token, assertionType, userName);
Next we instantiate our token cache. It uses a distributed cache, and we could configure for example a Redis cache in production environments, so that tokens are still in cache even after process restarts. By default the tokens are only stored in-memory.
You can see the token cache's source code here.
var cache = new DistributedTokenCache(user, _distributedCache, _loggerFactory, _dataProtectionProvider);
Then we do the usual ADAL token acquisition.
Create the AuthenticationContext
, to which we must tell what is our authority (e.g. https://login.microsoftonline.com/joonasapps.onmicrosoft.com
) and also the token cache to use.
We also create a ClientCredential
object to hold this API's credentials.
Then we acquire a token using the client credentials and user assertion. The first argument is the identifier for the API we want an access token for. So in short, it says:
- We want a token for the Microsoft Graph API
- I am this app and here is proof
- The currently signed in user is this
ADAL will then handle the proper OAuth call to get the tokens.
var authContext = new AuthenticationContext(_authSettings.Authority, cache);
var clientCredential = new ClientCredential(_authSettings.ClientId, _authSettings.ClientSecret);
var result = await authContext.AcquireTokenAsync(
"https://graph.microsoft.com",
clientCredential,
userAssertion);
After the call is done, ADAL will store the tokens in cache, so they will be available on the next request. This way your app saves the HTTP call on subsequent requests.
Next we assign the resulting token to an Authorization header.
request.Headers.Authorization =
new AuthenticationHeaderValue(result.AccessTokenType, result.AccessToken);
result.AccessTokenType
will be "Bearer"
, and so the resulting header looks something like this:
Authorization: Bearer eyJ0eXA.......
The call to Microsoft Graph API in the service layer is quite simple:
public async Task<User> GetUserProfileAsync()
{
var client = new GraphServiceClient(_msGraphAuthenticationProvider);
return await client.Me.Request().GetAsync();
}
Conclusions
This flow is not quite so easy to use as something like Client Credentials, but it is still quite doable. The examples used here do additional work that is not absolutely mandatory as well, like caching the access tokens.
But this scenario is super common and I considered this an exercise for myself.
I hope this proves useful to you :)