Web API to Azure AD Protected Web API using MSAL

Web API to Azure AD Protected Web API using MSAL

Many organizations are now moving to Microservices and APIs calling other APIs is very common in Microservices architecture.

In this article, let’s see how a secured API can call another secured API, both secured using Azure AD.

Protected Web App and API

The Web App (.NET Core Web Application) will call the web API, both are protected using Azure AD. Let’s call this API as caller API in Azure app registration and while registering this API’s scope, name the scope as “caller-api“.

Refer my previous blog for creating and configuring these two projects.

Steps for creating a web app and web API

Create New Web API project

Create two web API projects using Visual Studio. Register both of them in Azure AD and get client id and tenant id. Configure the middleware and make both APIs secure.

Refer my previous blog article about securing web APIs for detailed steps.

Secure Web API using Azure AD

Complete Applications Setup

After following all above steps, we should have

  • a protected web application that calls one web API (let’s say caller API)
  • a protected caller API, which is protected using Azure AD and scope name is “caller-api“.
  • one more protected API project, which is also protected using Azure AD, but nobody calls it yet and the scope name is “access_as_user”

We will try to call this new API, from the caller API.

Azure AD App Registration

Now, let’s login to Azure Portal and go to the Caller API registration. We need to configure two more things in caller API app registration.

Client Secret

The caller API is calling another API, so it becomes confidential client application. So, we need to go to caller API's app registration and generate the client secret.

Copy this client secret and keep it at some place safe, we will need to add it to caller API's configuration file.

Client Secret for Caller API

Permission

Next, thing to add the permission to call the new API. From API Permissions in left navigation, select Add a permission and then select My APIs to see your newly registered API. Add this permission so that caller API can call the new API.

Add permission in caller API to call new API

Caller API Configurations

In the caller API configurations, we need to add the client secret. Below is the complete configuration file in my sampler caller API project. Just do not forget to update the client secret.

{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "da41245a5-11b3-996c-00a8-4d99re19f292",
"ClientId": "262f3e7b-20cb-42cb-8a23-2d6c8003ee3a",
"ClientSecret": "P7.BVieTYOwE4vvert_qqt33uqm7R~ia_V9"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}
view raw appsettings.json5 hosted with ❤ by GitHub

Caller API Startup

In the caller API, we need to ensure that the token of calling user is cached and used for the other API call. There are two main things which are required to be done here.

Startup Code

In startup of caller API, we need to add middleware for caching the incoming tokens. In Microsoft.Identity.Web 0.1.3 package, we can find an extension method, AddProtectedWebApiCallsProtectedWebApi, which abstracts all the details of validating token and adding it to cache.

Below snippet shows the completed startup.cs file and all middlewares are added there.

public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddProtectedWebApi(Configuration)
.AddProtectedWebApiCallsProtectedWebApi(Configuration)
.AddInMemoryTokenCaches();
services.AddControllers();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
app.UseRouting();
// NOTE: this is required for Angular Web App
// localhost:44380 is for WebAppAngular project
// localhost:4200 is required if you use angular CLI
// to create app and run it using ng serve
// You may want to add the caller API entry depending on your
// project configurations
app.UseCors(builder =>
{
builder.WithOrigins("https://localhost:44380")
.WithOrigins("http://localhost:4200")
.AllowCredentials()
.AllowAnyMethod()
.AllowAnyHeader();
});
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
view raw Startup.cs hosted with ❤ by GitHub

Controller Code

Then ITokenAcquisition instance can be used to get the token. This token can be added to Authorization header as bearer token and then we can call the new API.

Below snippet shows the code where ITokenAcquisition instance is injected in the controller. Then the same instance is used to get the cached token and injected in the HTTP request to the new API.

[Authorize]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private readonly string[] scopeRequiredByApi = new string[] { "caller-api" };
private readonly ILogger<WeatherForecastController> _logger;
private readonly ITokenAcquisition _tokenAcquisition;
public WeatherForecastController(ILogger<WeatherForecastController> logger, ITokenAcquisition tokenAcquisition)
{
_logger = logger;
_tokenAcquisition = tokenAcquisition;
}
[HttpGet]
public async Task<IEnumerable<WeatherForecast>> Get()
{
HttpContext.VerifyUserHasAnyAcceptedScope(scopeRequiredByApi);
return await CallOtherApi();
}
private async Task<dynamic> CallOtherApi()
{
// scopes required to access the new weather forecast service
string[] scopes = new string[] { "user.read", "api://5e999e55-a661-4982-b897-965480492129/access_as_user" };
// Get cached token
string accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes);
HttpClient client = new HttpClient();
// After the token has been returned by Microsoft Authentication Library (MSAL),
// add it to the HTTP authorization header before making the call
// to access the weather forecast service.
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
// Call the To Do list service.
string url = "https://localhost:44389/weatherforecast";
HttpResponseMessage response = await client.GetAsync(url);
string content = await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.OK)
{
return JsonConvert.DeserializeObject<List<WeatherForecast>>(content);
}
throw new Exception("api call failed");
}
}

Web App Modifications

Initially, I assumed that it is logical to specify both the APIs scope in the web app, so that the token we get will be applicable for both the APIs.

So, I specified both access_as_user and caller-api scopes as shown below in startup.

services.AddSignIn(Configuration, "AzureAd");
services.AddWebAppCallsProtectedWebApi(Configuration,
initialScopes: new string[] { "user.read",
// scope of new API
"api://5e999e55-a661-4982-b897-965480492129/access_as_user",
// Scope of caller API
"api://262f3e7b-20cb-42cb-8a23-2d6c8003ee3a/caller-api"
})
.AddInMemoryTokenCaches();
view raw Startup.cs hosted with ❤ by GitHub

Also, on the button click, added below code to call the caller-api, which also asked for scopes of both the APIs.

//// Acquire the access token.
string[] scopes = new string[] { "user.read", "api://5e999e55-a661-4982-b897-965480492129/access_as_user", "api://262f3e7b-20cb-42cb-8a23-2d6c8003ee3a/caller-api" };
string accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes);
//// API URL
string url = "https://localhost:44370/weatherforecast";
HttpClient client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
string json = await client.GetStringAsync(url);
var apiOutput = JsonConvert.DeserializeObject<List<WeatherForecast>>(json);
ViewData["ApiOutput"] = apiOutput;
view raw code.cs hosted with ❤ by GitHub

The Unexpected Error

Then when I tried to run the application, it threw an error. I was surprised to know that the scopes of both APIs can not be specified together.

MsalServiceException: AADSTS28000: Provided value for the input parameter scope is not valid because it contains more than one resource.

I could not find why the error was occurring from the documentation. But the error was stating that I have specified two resources and the scopes corresponding to our two APIs.

So, I thought may be two API scopes are not allowed in the initial scopes. But if they are not allowed in initial scopes requested for login, and if we just specify any one API scope, the token acquired will not have permissions to call the other API.

Failed Attempt To Resolve

To solve this issue, I thought I will remove the permission from startup file (from the snippet provided above) , but will keep both the permissions in the code in web app that calls the caller-api.

But this did not resolve the error. I was able to login successfully, and when I tried to call the API, I got the same error.

Successful Resolution

I went back to Azure Caller API registration and provided admin consent for default directory by clicking on the Grand admin consent for default directory button in caller-api’s app registration -> API Permissions screen.

I was sure that if I do this, it will provide the caller-api permission to call the other API, even though the user token has not requested that permission.

Updated startup file and the code that calls caller API, to only ask for the caller API scope.

services.AddSignIn(Configuration, "AzureAd");
services.AddWebAppCallsProtectedWebApi(Configuration,
initialScopes: new string[] {
"user.read",
// Scope of caller API
"api://262f3e7b-20cb-42cb-8a23-2d6c8003ee3a/caller-api"
})
.AddInMemoryTokenCaches();
view raw Startup.cs hosted with ❤ by GitHub

The code which calls API from the web application was also updated as below:

//// Acquire the access token.
string[] scopes = new string[] { "user.read", "api://262f3e7b-20cb-42cb-8a23-2d6c8003ee3a/caller-api" };
string accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes);
//// API URL
string url = "https://localhost:44370/weatherforecast";
HttpClient client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
string json = await client.GetStringAsync(url);
var apiOutput = JsonConvert.DeserializeObject<List<WeatherForecast>>(json);
ViewData["ApiOutput"] = apiOutput;
view raw code.cs hosted with ❤ by GitHub

Here the theory was if I get token which allows permission to call the caller api, then at least I will be able to login to the web application and will be able to issue call to it. The caller API would still be able to call

Run and Verify

I was successfully able to login in the web application and call the API which calls other API.

Successfuly call from web app to caller api to the other API

Did you find any other resolution instead of providing admin consent? Let me know your thoughts.

Leave a Reply