Securing Azure Functions using Azure AD JWT Bearer token authentication for user access tokens

This post shows how to implement OAuth security for an Azure Function using user-access JWT Bearer tokens created using Azure AD and App registrations. A client web application implemented in ASP.NET Core is used to authenticate and the access token created for the identity is used to access the API implemented using Azure Functions. Microsoft.Identity.Web is used to authenticate the user and the application.

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

Blogs in the series

Setup Azure Functions Auth

Using JWT Bearer tokens in Azure Functions is not supported per default. You need to implement the authorization and access token validation yourself, although ASP.NET Core provides many APIs which make this easy. I implemented this example based on the excellent blogs from Christos Matskas and Boris Wilhelms. Thanks for these.

The AzureADJwtBearerValidation class uses the Azure AD configuration and uses the configured values to fetch the Azure Active Directory well known endpoints for your tenant. The access token is validated and the required scope (access_as_user) is validated as well as the OAuth standard validations.

The claims from the access token are returned in a ClaimsPrincipal and can be used as required. The class can be extended to validate different scopes or whatever you require for your application.

using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;

namespace FunctionIdentityUserAccess
{
    public class AzureADJwtBearerValidation
    {
        private IConfiguration _configuration;
        private ILogger _log;
        private const string scopeType = @"http://schemas.microsoft.com/identity/claims/scope";
        private ConfigurationManager<OpenIdConnectConfiguration> _configurationManager;
        private ClaimsPrincipal _claimsPrincipal;

        private string _wellKnownEndpoint = string.Empty;
        private string _tenantId = string.Empty;
        private string _audience = string.Empty;
        private string _instance = string.Empty;
        private string _requiredScope = "access_as_user";

        public AzureADJwtBearerValidation(IConfiguration configuration, ILoggerFactory loggerFactory)
        {
            _configuration = configuration;
            _log = loggerFactory.CreateLogger<AzureADJwtBearerValidation>();

            _tenantId = _configuration["AzureAd:TenantId"];
            _audience = _configuration["AzureAd:ClientId"];
            _instance = _configuration["AzureAd:Instance"];
            _wellKnownEndpoint = $"{_instance}{_tenantId}/v2.0/.well-known/openid-configuration";
        }

        public async Task<ClaimsPrincipal> ValidateTokenAsync(string authorizationHeader)
        {
            if (string.IsNullOrEmpty(authorizationHeader))
            {
                return null;
            }

            if (!authorizationHeader.Contains("Bearer"))
            {
                return null;
            }

            var accessToken = authorizationHeader.Substring("Bearer ".Length);

            var oidcWellknownEndpoints = await GetOIDCWellknownConfiguration();
 
            var tokenValidator = new JwtSecurityTokenHandler();

            var validationParameters = new TokenValidationParameters
            {
                RequireSignedTokens = true,
                ValidAudience = _audience,
                ValidateAudience = true,
                ValidateIssuer = true,
                ValidateIssuerSigningKey = true,
                ValidateLifetime = true,
                IssuerSigningKeys = oidcWellknownEndpoints.SigningKeys,
                ValidIssuer = oidcWellknownEndpoints.Issuer
            };

            try
            {
                SecurityToken securityToken;
                _claimsPrincipal = tokenValidator.ValidateToken(accessToken, validationParameters, out securityToken);

                if (IsScopeValid(_requiredScope))
                {
                    return _claimsPrincipal;
                }

                return null;
            }
            catch (Exception ex)
            {
                _log.LogError(ex.ToString());
            }
            return null;
        }

        public string GetPreferredUserName()
        {
            string preferredUsername = string.Empty;
            var preferred_username = _claimsPrincipal.Claims.FirstOrDefault(t => t.Type == "preferred_username");
            if (preferred_username != null)
            {
                preferredUsername = preferred_username.Value;
            }

            return preferredUsername;
        }

        private async Task<OpenIdConnectConfiguration> GetOIDCWellknownConfiguration()
        {
            _log.LogDebug($"Get OIDC well known endpoints {_wellKnownEndpoint}");
            _configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
                 _wellKnownEndpoint, new OpenIdConnectConfigurationRetriever());

            return await _configurationManager.GetConfigurationAsync();
        }

        private bool IsScopeValid(string scopeName)
        {
            if (_claimsPrincipal == null)
            {
                _log.LogWarning($"Scope invalid {scopeName}");
                return false;
            }

            var scopeClaim = _claimsPrincipal.HasClaim(x => x.Type == scopeType)
                ? _claimsPrincipal.Claims.First(x => x.Type == scopeType).Value
                : string.Empty;

            if (string.IsNullOrEmpty(scopeClaim))
            {
                _log.LogWarning($"Scope invalid {scopeName}");
                return false;
            }

            if (!scopeClaim.Equals(scopeName, StringComparison.OrdinalIgnoreCase))
            {
                _log.LogWarning($"Scope invalid {scopeName}");
                return false;
            }

            _log.LogDebug($"Scope valid {scopeName}");
            return true;
        }
    }
}

When using Microsoft.IdentityModel.Protocols.OpenIdConnect you need to add the _FunctionsSkipCleanOutput to your Azure function project file, otherwise you will have runtime exceptions. System.IdentityModel.Tokens.Jwt is also required.

 <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <AzureFunctionsVersion>v3</AzureFunctionsVersion>
    <_FunctionsSkipCleanOutput>true</_FunctionsSkipCleanOutput>
    <LangVersion>latest</LangVersion>
 </PropertyGroup>

 <ItemGroup>
    <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="3.0.9" />
    <PackageReference Include="Microsoft.Azure.Functions.Extensions" Version="1.1.0" />
    <PackageReference Include="Microsoft.Azure.WebJobs.Extensions.Storage" Version="4.0.2" />
    <PackageReference Include="Microsoft.Azure.KeyVault" Version="3.0.5" />
    
    <PackageReference Include="Microsoft.Extensions.Configuration.AzureKeyVault" Version="3.1.8" />
    <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="3.1.8" />
    <PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.8" />
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="3.1.8" />
    <PackageReference Include="System.Configuration.ConfigurationManager" Version="4.7.0" />
    
    <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.7.1" />
    <PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="6.7.1" />
  </ItemGroup>

The AzureADJwtBearerValidation service is added to the DI in the startup class.

[assembly: FunctionsStartup(typeof(Startup))]
namespace FunctionIdentityUserAccess
{

    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            builder.Services.AddScoped<AzureADJwtBearerValidation>();
        }

Add the AzureAd configurations to the local settings as required and also to the Azure Functions configurations in the portal.

  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "TenantId": "[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App registrations' blade and use the GUID in any of the URLs), e.g. da41245a5-11b3-996c-00a8-4d99re19f292]",
    "ClientId": "[Enter the Client Id (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]"
  }

The Azure function RandomString can use the AzureADJwtBearerValidation service to validate the access token and get the claims back as required. If the access token is invalid, then a 401 is returned, otherwise the response as required.

namespace FunctionIdentityUserAccess
{
    public class RandomStringFunction
    {
        private readonly ILogger _log;
        private readonly AzureADJwtBearerValidation _azureADJwtBearerValidation;

        public RandomStringFunction(ILoggerFactory loggerFactory,
            AzureADJwtBearerValidation azureADJwtBearerValidation)
        {
            _log = loggerFactory.CreateLogger<RandomStringFunction>();;
            _azureADJwtBearerValidation = azureADJwtBearerValidation;
        }

        [FunctionName("RandomString")]
        public async Task<IActionResult> RandomString(
            [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req)
        {
            try
            {
                _log.LogInformation("C# HTTP trigger RandomStringAuthLevelAnonymous processed a request.");
                
                ClaimsPrincipal principal; // This can be used for any claims
                if ((principal = await _azureADJwtBearerValidation.ValidateTokenAsync(req.Headers["Authorization"])) == null)
                {
                    return new UnauthorizedResult();
                }

                return new OkObjectResult($"Bearer token claim preferred_username: {_azureADJwtBearerValidation.GetPreferredUserName()}  {GetEncodedRandomString()}");
            }
            catch (Exception ex)
            {
                return new OkObjectResult($"{ex.Message}");
            }
        }

Azure App Registrations

Azure App Registrations is used to setup the Azure AD configuration is described in this blog.

Login and use an ASP.NET Core API with Azure AD Auth and user access tokens

The Microsoft.Identity.Web also provides great examples and docs on how to configure or to create the App registration as required for your use case.

Setup Web App

The ASP.NET Core application uses Azure AD to login and access the Azure Function using the access token to get the data from the function. The Web application uses AddMicrosoftIdentityWebAppAuthentication for authentication and the will get an access token for the API. The EnableTokenAcquisitionToCallDownstreamApi is used the setup the API auth with your initial scopes.

public void ConfigureServices(IServiceCollection services)
{
	services.AddHttpClient();

	services.AddOptions();

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

	services.AddMicrosoftIdentityWebAppAuthentication(Configuration)
		.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
		.AddInMemoryTokenCaches();

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

public void Configure(IApplicationBuilder app)
{
	app.UseHttpsRedirection();
	app.UseStaticFiles();

	app.UseRouting();

	app.UseAuthentication();
	app.UseAuthorization();

	app.UseEndpoints(endpoints =>
	{
		endpoints.MapRazorPages();
	});
}

The OnGetAsync method of a Razor page calls the Azure Function API using the access token from the AAD.

private readonly ILogger<IndexModel> _logger;
private readonly IHttpClientFactory _clientFactory;
private readonly IConfiguration _configuration;
private readonly ITokenAcquisition _tokenAcquisition;

[BindProperty]
public string RandomString {get;set;}

public IndexModel(IHttpClientFactory clientFactory, 
	ITokenAcquisition tokenAcquisition, 
	IConfiguration configuration, 
	ILogger<IndexModel> logger)
{
	_logger = logger;
	_clientFactory = clientFactory;
	_configuration = configuration;
	_tokenAcquisition = tokenAcquisition;
}

public async Task OnGetAsync()
{
	var client = _clientFactory.CreateClient();

	var scope = _configuration["CallApi:ScopeForAccessToken"];
	var accessToken = await _tokenAcquisition
		.GetAccessTokenForUserAsync(new[] { scope });

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

	RandomString = await client.GetStringAsync(
		_configuration["CallApi:FunctionsApiUrl"]);
}

When the applications are started, the Razor page Web APP can be used to login and after a successful login, it gets the perferred_name claim from the Azure Function if the access token is authorized to access the Azure function API.

Notes

This Azure Functions solution would be the way to access functions from a SPA application. If using server rendered applications, you have other possibilities to setup the authorization.

Azure Functions does not provide any out-of-the-box solutions for JWT Bearer token authorization or introspection with reference tokens, which is not optimal. If implementing only APIs, ASP.NET Core Web API projects would be a better solution where standard authorization flows, standard libraries and better tooling are per default.

Microsoft.Identity.Web is great for authentication when using explicitly with Azure AD and no other authentication systems. In-memory cache is a problem when using this together with Web APP and APIs.

Links

https://cmatskas.com/create-an-azure-ad-protected-api-that-calls-into-cosmosdb-with-azure-functions-and-net-core-3-1/

https://anthonychu.ca/post/azure-functions-app-service-openid-connect-auth0/

https://docs.microsoft.com/en-us/azure/app-service/configure-authentication-provider-openid-connect

https://github.com/Azure/azure-functions-vs-build-sdk/issues/397

https://blog.wille-zone.de/post/secure-azure-functions-with-jwt-token/#secure-azure-functions-with-jwt-access-tokens

https://github.com/AzureAD/microsoft-identity-web

https://github.com/Azure-Samples/active-directory-aspnetcore-webapp-openidconnect-v2

https://jwt.io/

https://winsmarts.com/use-microsoft-identity-web-with-azure-functions-2a5c52824578

7 comments

  1. […] Securing Azure Functions using Azure AD JWT Bearer token authentication for user access tokens (Damien Bowden) […]

  2. […] Securing Azure Functions using Azure AD JWT Bearer token authentication for user access tokens – Damien Bowden […]

  3. Paul Saxton · · Reply

    Hello

    How can I use this with my own identity server? I dont want to have the MS Login

    Paul

    1. Hi Paul

      If you look at the links at the bottom, there are examples from other blogs using other IDPs. The is just standard JWT checks and should work with any IDP.

      Greetings Damien

      1. Paul saxton · ·

        Ok I will take look when I get back to computer

        I basically already have my own identity server

        I am trying to mimic what would happen with authorise and middleware in apis

        Paul

  4. How did you authenticate to get the token? I got it to work with the v1 address but v2 i changed the resource header to be scope and got the token, but getting invalid audience.

    I have client_id, client_secret, grant_type=client_credentials, scope=api://e3454ce0-6182-4e44-94d6-xxxxxxxxxxxx/.default where client_id and client_secret is my access app registration and the scope app id is the app that’s im authenticating for.

  5. […] This post shares the approach. For daemon-generated tokens, we need though to substitute the oidcWellknownEndpoints.Issuer in TokenValidationParameters object instance with the following entry to make the token validation process pass successfully: […]

Leave a comment

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