Securing Web APIs with Azure AD: Connecting External Clients

Posts in this series:

Full example

In the previous post, we looked at enabling local development by creating an Azure AD Group, assigning our user (me) to that group, and assigning the appropriate App Roles (modeled as permissions) to that group. In this post, we'll be doing similar with an external client. As a reminder, our network communication is roughly:

Network security flows with on-prem using Azure AD

We want to use Azure AD to authenticate and authorize communication from on-prem (external) clients to our Azure-hosted API, but how? You guessed it - another App Registration! In addition to App Registrations defining security rules for itself (what OAuth protocols may be used, what scopes to grant, App Roles etc.), we can also assign an App Registration's service principal (once created) App Roles for another App Registration.

Creating the Azure Resources

First, let's create an App Registration for our external client:

var application = new AzureAD.Application($"{prefix}-{AppName}",
    new AzureAD.ApplicationArgs
    {
        DisplayName = "Azure AD Example External Client",
        Api = new AzureAD.Inputs.ApplicationApiArgs
        {
            RequestedAccessTokenVersion = 2,
        }
    });

In order to assign this App Registration our App Roles for the server, it needs a principal:

var servicePrincipal = new AzureAD.ServicePrincipal(
    $"{prefix}-{AppName}-service-principal",
    new AzureAD.ServicePrincipalArgs
    {
        ApplicationId = application.ApplicationId,
    });

This creates a Service Principal attached to our Application. We don't assign App Roles to the Application itself, but instead the Service Principal (roles can only be assigned to Principals).

When our application runs, it needs to establish its identity and acquire a token for our server resource. The actual on-prem server application(s) use a variety of authentication mechanisms internally - forms cookie authentication, or for daemon services, nothing.

We can't reuse the on-prem web app token, there isn't one. Instead, we'll use OAuth 2.0 Client Credentials flow. To acquire a token, we have a few options:

  • Shared (client) secret
  • Certificate
  • Federated credential

We don't have a credential to federate so option #3 is out. Between client secrets and certificates, I tend to prefer certificates but for simplicity's sake, let's use client secrets. It doesn't matter terribly for our example. We create our Application Password:

var applicationSecret = new AzureAD.ApplicationPassword(
    $"{prefix}-{AppName}-password",
    new AzureAD.ApplicationPasswordArgs
    {
        ApplicationObjectId = application.ObjectId
    }, new CustomResourceOptions
    {
        AdditionalSecretOutputs =
        {
            "value"
        }
    });

And create a "secret" output in Pulumi, which we can later store in Azure Key Vault. If we went the certificate route, we could create a Key Vault certificate directly and assign that certificate to our App Registration. I'm not going that far for this example - this client secret will be stored in User Secrets for Visual Studio debugging.

Finally, we need to assign the Service Principal our App Roles:

var externalClient = new ExternalClient(prefix);

server.AssignRoles(prefix, externalClient);

To test some different scenarios, we'll only grant Read access:

public void AssignRoles(string prefix, ExternalClient externalClient)
{
    AssignRead(prefix, 
        ExternalClient.AppName, 
        externalClient.ApplicationServicePrincipalObjectId);
}

After a pulumi up, we can see that our App Registration has been granted the Read permission (App Role):

External Client granted Todo.Read access

Now that we've got our Azure resources created, let's acquire a token and call this API from our external client application.

Calling the API using Client Credentials

When using the Client Credentials OAuth 2.0 flow to acquire a token with Azure AD, we'll need to use MSAL.NET (the Microsoft.Identity.Client package) library:

<PackageReference Include="Microsoft.Identity.Client" Version="4.47.1" />

Next, we'll need some configuration values (pulled from Pulumi's outputs):

  • Client ID of the calling App Registration
  • Client Secret (stored in User Secrets)
  • Application ID of the Server
  • Tenant ID (Azure AD)

I'll put these in my appsettings.json file:

"ClientId": "bb301f02-0a55-40d3-b94e-ec0f93c2d2db",
"Server": {
  "ApplicationId": "f12ec5af-617f-4363-bcc6-e23bc9e813dd"
},
"TenantId": "e56b135d-b0e0-4ad8-8faa-1ca3915fe4b2"

To acquire tokens, we can use the ConfidentialClientApplicationBuilder class and register this with the container:

var clientId = context.Configuration["ClientId"];
var clientSecret = context.Configuration["ClientSecret"];
var tenantId = context.Configuration["TenantId"];

services.AddSingleton(_ =>
{
    var app = ConfidentialClientApplicationBuilder.Create(clientId)
        .WithClientSecret(clientSecret)
        .WithAuthority(new Uri($"https://login.microsoftonline.com/{tenantId}/"))
        .Build();

    return app;
});

There are separate methods for using certificates or federated tokens. Finally, we want to create a Typed HttpClient using an IHttpClientFactory to automatically acquire a token when we make API calls. To do so, I'll first register some configuration objects that our custom HttpClient wrapper can use:

services.Configure<AzureAdServerApiOptions<ITodoItemsClient>>(
    context.Configuration.GetSection("Server"));
services.Configure<AzureAdServerApiOptions<IWeatherForecastClient>>(
    context.Configuration.GetSection("Server"));

These Option types gather all the necessary Azure AD config:

public class AzureAdServerApiOptions<TClient>
{
    public string ApplicationId { get; set; } = null!;
    public string BaseAddress { get; set; } = null!;
}

I make this generic according to the client, as each client may have a different ApplicationId and BaseAddress (they may be different APIs). We have to acquire a token for each unique Application we call as each Application defines its own App Roles. The custom API client's interface can be generated from the OpenAPI definition or manually created:

public interface ITodoItemsClient
{
    Task<IEnumerable<TodoItem>?> GetAsync();
    Task<TodoItem?> GetAsync(long id);
    Task PutAsync(long id, TodoItem todoItem);
    Task<TodoItem?> PostAsync(TodoItem todoItem);
    Task DeleteAsync(long id);
}

And the implementation takes an HttpClient and makes the individual API calls:

public class TodoItemsClient : ITodoItemsClient
{
    private readonly HttpClient _client;

    public TodoItemsClient(HttpClient client)
    {
        _client = client;
    }

    public async Task<IEnumerable<TodoItem>?> GetAsync()
    {
        return await _client.GetFromJsonAsync<IEnumerable<TodoItem>>(
            "/api/todoitems"
            );
    }

You don't see the code to acquire a token, as I've wrapped that bit up in some custom middleware using a DelegatingHandler. This custom handler needs to acquire the JWT for the server API it's calling, but each server API is different so we want to only get the API configuration for that individual API:

public class ConfidentialClientApplicationAuthHandler<TClient> 
    : DelegatingHandler
{
    private readonly IConfidentialClientApplication _app;
    private readonly ILogger<ConfidentialClientApplicationAuthHandler<TClient>> _logger;
    private readonly AzureAdServerApiOptions<TClient> _options;

    public ConfidentialClientApplicationAuthHandler(IConfidentialClientApplication app, 
        ILogger<ConfidentialClientApplicationAuthHandler<TClient>> logger,
        IOptions<AzureAdServerApiOptions<TClient>> options)
    {
        _app = app;
        _logger = logger;
        _options = options.Value;
    }

By using that generic parameter TClient, we get the correct API options object for whatever client we register in the container. This is an example of using generic parameters as a sort of "strategy" pattern. We can then override the SendAsync method to acquire a token, attach it as the Authorization header, and make the underlying API call:

protected override async Task<HttpResponseMessage> SendAsync(
    HttpRequestMessage request, 
    CancellationToken cancellationToken)
{
    var scopes = new[] { _options.ApplicationId + "/.default" };
    var result = await _app.AcquireTokenForClient(scopes)
        .ExecuteAsync(cancellationToken);

    _logger.LogInformation(result.AccessToken);

    request.Headers.Authorization 
        = new AuthenticationHeaderValue("Bearer", result.AccessToken);

    return await base.SendAsync(request, cancellationToken);
}

I'm logging the access token just for demo purposes, probably don't do that in production.

Now we can register our custom HttpClient with the appropriate middleware in our container:

services.AddTransient<
    ConfidentialClientApplicationAuthHandler<IWeatherForecastClient>>();
services.AddTransient<
    ConfidentialClientApplicationAuthHandler<ITodoItemsClient>>();

services.AddHttpClient<IWeatherForecastClient, WeatherForecastClient>()
    .ConfigureHttpClient((sp, client) =>
    {
        var serverOptions = sp.GetRequiredService<
            IOptions<AzureAdServerApiOptions<IWeatherForecastClient>>>();
        client.BaseAddress = new Uri(serverOptions.Value.BaseAddress);
    })
    .AddHttpMessageHandler<
        ConfidentialClientApplicationAuthHandler<IWeatherForecastClient>>();
services.AddHttpClient<ITodoItemsClient, TodoItemsClient>()
    .ConfigureHttpClient((sp, client) =>
    {
        var serverOptions = sp.GetRequiredService<
            IOptions<AzureAdServerApiOptions<ITodoItemsClient>>>();
        client.BaseAddress = new Uri(serverOptions.Value.BaseAddress);
    })
    .AddHttpMessageHandler<
        ConfidentialClientApplicationAuthHandler<IWeatherForecastClient>>();

Now we can use this custom typed client and make requests, seamlessly acquiring a token underneath the covers:

public class WeatherForecastWorker : BackgroundService
{
    private readonly ILogger<WeatherForecastWorker> _logger;
    private readonly IWeatherForecastClient _client;

    public WeatherForecastWorker(
        ILogger<WeatherForecastWorker> logger, 
        IWeatherForecastClient client)
    {
        _logger = logger;
        _client = client;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            IEnumerable<WeatherForecast>? response;

            try
            {
                response = await _client.GetAsync();
            }
            catch (HttpRequestException e)
            {
                _logger.LogError(e, "Got error making connection.");
                return;
            }

            _logger.LogInformation(JsonSerializer.Serialize(response));

            await Task.Delay(5000, stoppingToken);
        }
    }
}

Here I just have a background worker making API requests every 5 seconds. Looking at the logs, we can see our JWT:

{
  "typ": "JWT",
  "alg": "RS256",
  "kid": "2ZQpJ3UpbjAYXYGaXEJl8lV0TOI"
}.{
  "aud": "f12ec5af-617f-4363-bcc6-e23bc9e813dd",
  "iss": "https://login.microsoftonline.com/e56b135d-b0e0-4ad8-8faa-1ca3915fe4b2/v2.0",
  "iat": 1664269101,
  "nbf": 1664269101,
  "exp": 1664273001,
  "aio": "E2ZgYDB2a5m/o/7y1tu235ZcM+brAAA=",
  "azp": "bb301f02-0a55-40d3-b94e-ec0f93c2d2db",
  "azpacr": "1",
  "oid": "1d377c1b-ea04-40e9-9de1-b19c04546395",
  "rh": "0.AQ4AXRNr5eCw2EqPqhyjkV_ksq_FLvF_YWNDvMbiO8noE90OAAA.",
  "roles": [
    "Todo.Read"
  ],
  "sub": "1d377c1b-ea04-40e9-9de1-b19c04546395",
  "tid": "e56b135d-b0e0-4ad8-8faa-1ca3915fe4b2",
  "uti": "ct9vBoxypEKvdrvt_7NOAA",
  "ver": "2.0"
}.[Signature]

And because we only assigned the Todo.Read role to our client application, only that role appears in our claims. If we try to make a request to modify data, it requires the Todo.Write claim, and we'll get an appropriate 403 Forbidden response:

info: System.Net.Http.HttpClient.ITodoItemsClient.LogicalHandler[101]
      End processing HTTP request after 91.9279ms - 403
fail: ExternalClient.TodoItemsWorker[0]
      Got error making connection.
      System.Net.Http.HttpRequestException: Response status code does not indicate success: 403 (Forbidden).
         at System.Net.Http.HttpResponseMessage.EnsureSuccessStatusCode()

Hooray, it all works! It takes a little bit to set up, but much of that middleware is reusable as we can plug in any typed HttpClient so that the appropriate configuration is plugged in at runtime.

In the next post, we'll look at Azure clients that can use a Managed Identity to authorize against our API.