Implement a Web APP and an ASP.NET Core Secure API using Microsoft Entra ID which delegates to a second API

This article shows how an ASP.NET Core Web application can authenticate and access a downstream API using user access tokens and delegate to another API in Microsoft Entra ID also using user access tokens. Microsoft.Identity.Web is used in all three applications to acquire the tokens afor the Web API and the access tokens for the two APIs.

Code: https://github.com/damienbod/MicrosoftEntraIDAuthMicrosoftIdentityWeb

Posts in this series

History

  • 2023-11-29 Updated to .NET 8

Setup and App registrations

The applications are setup as follows.

The applications implement the OAuth 2.0 On-Behalf-Of flow (OBO) and is made easy be using the Microsoft.Identity.Web Nuget packages.

The three applications require App registrations. The first Azure App registration exposes an API using the access_as_user scope. Nothing more is required here. This is the API at the end of the chain.

The API in the middle requires the API permission from the previously created App registration and exposes its own API, again the access_as_user scope. The Web API requires a secret to get the delegated access token and so a client secret is configured in this App registration. (Or a client certificate).

The API permissions is setup to use the scope from the other API.

And it exposes it’s own access_as_user scope.

The Web App requires a Web setup with a client secret (or client certificate) and the API permission from the middle API is added here.

Web Application which calls the first API

The Web APP with the UI interaction uses two Nuget packages, Microsoft.Identity.Web and Microsoft.Identity.Web.UI to implement the authentication and the authorization client for the API. The application is setup to acquire an access token using the EnableTokenAcquisitionToCallDownstreamApi method with the scope from the User API One.

builder.Services.AddTransient<UserApiOneService>();
builder.Services.AddHttpClient();

builder.Services.AddOptions();
builder.Services.AddDistributedMemoryCache();

string[]? initialScopes = builder.Configuration.GetValue<string>(
    "UserApiOne:ScopeForAccessToken")?.Split(' ');

builder.Services.AddMicrosoftIdentityWebAppAuthentication(
        builder.Configuration, "AzureAd", subscribeToOpenIdConnectMiddlewareDiagnosticsEvents: true)
    .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
    .AddDistributedTokenCaches();

builder.Services
    .AddAuthorization(options =>
    {
        options.FallbackPolicy = options.DefaultPolicy;
    });

builder.Services.AddRazorPages()
    .AddMvcOptions(options =>
    {
        var policy = new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .Build();
        options.Filters.Add(new AuthorizeFilter(policy));
    }).AddMicrosoftIdentityUI();

builder.Services.AddServerSideBlazor()
    .AddMicrosoftIdentityConsentHandler();

The two nuget packages are added to the csproj file.

  • Microsoft.Identity.Web
  • Microsoft.Identity.Web.UI

The configuration is setup to use the data for the applications defined in the APP registrations. The scope matches the scope from the User API One.

  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "damienbodhotmail.onmicrosoft.com",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "46d2f651-813a-4b5c-8a43-63abcb4f692c",
    "CallbackPath": "/signin-oidc",
    "SignedOutCallbackPath ": "/signout-callback-oidc"
  },
  "UserApiOne": {
    "ScopeForAccessToken": "api://b2a09168-54e2-4bc4-af92-a710a64ef1fa/access_as_user",
    "ApiBaseAddress": "https://localhost:44395"
  },

The API client implementation uses the ITokenAcquisition to get the access token for the identity and access the API.

using Microsoft.Identity.Web;
using Newtonsoft.Json.Linq;
using System.Net.Http.Headers;

namespace WebAppUserApis;

public class UserApiOneService
{
    private readonly IHttpClientFactory _clientFactory;
    private readonly ITokenAcquisition _tokenAcquisition;
    private readonly IConfiguration _configuration;

    public UserApiOneService(IHttpClientFactory clientFactory, 
        ITokenAcquisition tokenAcquisition, 
        IConfiguration configuration)
    {
        _clientFactory = clientFactory;
        _tokenAcquisition = tokenAcquisition;
        _configuration = configuration;
    }

    public async Task<JArray> GetApiDataAsync()
    {

        var client = _clientFactory.CreateClient();

        var scope = _configuration["UserApiOne:ScopeForAccessToken"];
        if (scope == null) throw new ArgumentNullException(nameof(scope));

        var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { scope });

        var uri = _configuration["UserApiOne:ApiBaseAddress"];
        if (uri == null) throw new ArgumentNullException(nameof(uri));

        client.BaseAddress = new Uri(uri);
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

        var response = await client.GetAsync("weatherforecast");
        if (response.IsSuccessStatusCode)
        {
            var responseContent = await response.Content.ReadAsStringAsync();
            var data = JArray.Parse(responseContent);

            return data;
        }

        throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
    }
}

The Web App requires a user secret to access and authenticate. This could also be done using a client certificate. A client secret is used in this example and this must match the secret setup in the Web App registration.

{ 
  "AzureAd" : { 
    "ClientSecret" : "--your secret for WebApp App Registration--" 
  } 
}

API which calls the second API

The UI facing API uses a second API for separate data. The second API is also a user access token API and uses delegated tokens to access the data it protects. The API is not used from the UI application. When the access token from the the UI application is used to access the first API, it uses this to get another token to access the access token. This is all setup in the Startup class of the UI facing API. The AddMicrosoftIdentityWebApiAuthentication method is used to setup the API and it enables token acquisition for the second API. This is very simple when using Microsoft.Identity.Web.

builder.Services.AddTransient<UserApiTwoService>();
builder.Services.AddHttpClient();
builder.Services.AddOptions();

builder.Services.AddDistributedMemoryCache();

builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration, "AzureAd")
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddDistributedTokenCaches();

builder.Services.AddSwaggerGen(c =>
{
    // add JWT Authentication
    var securityScheme = new OpenApiSecurityScheme
    {
        Name = "JWT Authentication",
        Description = "Enter JWT Bearer token **_only_**",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.Http,
        Scheme = "bearer", // must be lower case
        BearerFormat = "JWT",
        Reference = new OpenApiReference
        {
            Id = JwtBearerDefaults.AuthenticationScheme,
            Type = ReferenceType.SecurityScheme
        }
    };
    c.AddSecurityDefinition(securityScheme.Reference.Id, securityScheme);
    c.AddSecurityRequirement(new OpenApiSecurityRequirement
            {
                {securityScheme, Array.Empty<string>()}
            });

    c.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "User API One",
        Version = "v1",
        Description = "User API One",
        Contact = new OpenApiContact
        {
            Name = "damienbod",
            Email = string.Empty,
            Url = new Uri("https://damienbod.com/"),
        },
    });
});

builder.Services.AddControllers(options =>
{
    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        // .RequireClaim("email") // disabled this to test with users that have no email (no license added)
        .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
});

The app.settings are configured to use the Microsoft Entra ID API registration and the scope for the second application.

  "AzureAd": { // App Registration UserApiOne
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "damienbodhotmail.onmicrosoft.com",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "b2a09168-54e2-4bc4-af92-a710a64ef1fa"
  },
  "UserApiTwo": {
    // This is the OBO flow, ie the default scope can be used. You can also used the scope name directly to configure.
    //"ScopeForAccessToken": "api://72286b8d-5010-4632-9cea-e69e565a5517/.default",
    "ScopeForAccessToken": "api://72286b8d-5010-4632-9cea-e69e565a5517/user_impersonation",
    "ApiBaseAddress": "https://localhost:44396"
  },

The UserApiTwoService gets an access token for the API two scope and this is used to access the Web API controllers to return the data.

using Microsoft.Identity.Web;
using System.Net.Http.Headers;
using System.Text.Json;

namespace UserApiOne;

public class UserApiTwoService
{
    private readonly IHttpClientFactory _clientFactory;
    private readonly ITokenAcquisition _tokenAcquisition;
    private readonly IConfiguration _configuration;

    public UserApiTwoService(IHttpClientFactory clientFactory, 
        ITokenAcquisition tokenAcquisition, 
        IConfiguration configuration)
    {
        _clientFactory = clientFactory;
        _tokenAcquisition = tokenAcquisition;
        _configuration = configuration;
    }

    public async Task<List<WeatherForecast>?> GetApiDataAsync()
    {
        var client = _clientFactory.CreateClient();

        // user_impersonation access_as_user access_as_application .default
        var scope = _configuration["UserApiTwo:ScopeForAccessToken"];
        if(scope == null) throw new ArgumentNullException(nameof(scope));
        
        var uri = _configuration["UserApiTwo:ApiBaseAddress"];
        if (uri == null) throw new ArgumentNullException(nameof(uri));

        var accessToken = await _tokenAcquisition
            .GetAccessTokenForUserAsync(new[] { scope });

        client.DefaultRequestHeaders.Authorization 
            = new AuthenticationHeaderValue("Bearer", accessToken);

        client.BaseAddress = new Uri(uri);
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

        var response = await client.GetAsync("weatherforecast");
        if (response.IsSuccessStatusCode)
        {
            var data = await JsonSerializer.DeserializeAsync<List<WeatherForecast>?>(
                await response.Content.ReadAsStreamAsync());

            return data;
        }

        throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
    }
}

The get an access token for the second API, a client secret or a client certificate is required. The client second is used and this is defined in the first Web API. This can be added to your user secrets or an Azure Key Vault.

Second API

The API two is configured in the Startup class to require Microsoft Entra ID delegrated access tokens. The AddMicrosoftIdentityWebApiAuthentication method is used with no extra configuration. Scopes and roles should be validated as well. This can be done her, or in policies or using the helper methods from the Microsoft Entra ID Microsoft.Identity.Web packages.

builder.Services.AddHttpClient();
builder.Services.AddOptions();

builder.Services.AddMicrosoftIdentityWebApiAuthentication(
    builder.Configuration, "AzureAd");

builder.Services.AddSwaggerGen(c =>
{
    // add JWT Authentication
    var securityScheme = new OpenApiSecurityScheme
    {
        Name = "JWT Authentication",
        Description = "Enter JWT Bearer token **_only_**",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.Http,
        Scheme = "bearer", // must be lower case
        BearerFormat = "JWT",
        Reference = new OpenApiReference
        {
            Id = JwtBearerDefaults.AuthenticationScheme,
            Type = ReferenceType.SecurityScheme
        }
    };
    c.AddSecurityDefinition(securityScheme.Reference.Id, securityScheme);
    c.AddSecurityRequirement(new OpenApiSecurityRequirement
            {
                {securityScheme, Array.Empty<string>()}
            });

    c.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "User API Two",
        Version = "v1",
        Description = "User API Two",
        Contact = new OpenApiContact
        {
            Name = "damienbod",
            Email = string.Empty,
            Url = new Uri("https://damienbod.com/"),
        },
    });
});

builder.Services.AddControllers(options =>
{
    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        // .RequireClaim("email") // disabled this to test with users that have no email (no license added)
        .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
});

The Microsoft Entra ID configuration in the app.settings are standard like in the documentation.

  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "damienbodhotmail.onmicrosoft.com",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "72286b8d-5010-4632-9cea-e69e565a5517"
  },

The VerifyUserHasAnyAcceptedScope can be used to validate a required scope for the delegated access token.

[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
	//string[] scopeRequiredByApi = new string[] { "access_as_user" };
	string[] scopeRequiredByApi = new string[] { "user_impersonation" };
		
		var rng = new Random();
	return Enumerable.Range(1, 5).Select(index => new WeatherForecast
	{
		Date = DateTime.Now.AddDays(index),
		TemperatureC = rng.Next(-20, 55),
		Summary = Summaries[rng.Next(Summaries.Length)]
	})
	.ToArray();
}

When the applications are run, the UI web application authenticates and gets an access token for Web API one. Web API one authorizes the access token and gets an access token for Web API two. Web API two authorizes the access token and returns the data. Web API one gets the data from Web API two and then returns data to the Web App. The full request chain works and uses user access tokens without making the second API available to the UI application.

Links

https://docs.microsoft.com/en-us/azure/active-directory/manage-apps/howto-saml-token-encryption

Authentication and the Azure SDK

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#second-case-access-token-request-with-a-certificate

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/Client-credential-flows

https://tools.ietf.org/html/rfc7523

https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication

https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/Client-Assertions

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow

https://github.com/AzureAD/microsoft-identity-web/wiki/Using-certificates#describing-client-certificates-to-use-by-configuration

API Security with OAuth2 and OpenID Connect in Depth with Kevin Dockx, August 2020

https://www.scottbrady91.com/OAuth/Removing-Shared-Secrets-for-OAuth-Client-Authentication

https://github.com/KevinDockx/ApiSecurityInDepth

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki

https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-protected-web-api-verification-scope-app-roles

4 comments

  1. […] Implement a Web APP and an ASP.NET Core Secure API using Azure AD which delegates to second API (Damien Bowden) […]

  2. […] Implement a Web APP and an ASP.NET Core Secure API using Azure AD which delegates to a second API – Damien Bowden […]

  3. […] access token which the frontend API got by using the OBO flow, is implemented like in this blog: Implement a Web APP and an ASP.NET Core Secure API using Azure AD which delegates to a second API. Again the azpacr claim is used to check that a client secret was used to get the access token […]

  4. Hi Damien

    Thanks for the detailed walkthrough of this setup.

    Where in the process does the end user grant consent to the UI facing API to allow it to call the second API downstream? Did you grant consent through the Azure portal before using the web app, or can a user grant consent directly through the web app (if not already granted)?

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.