Securing Azure Functions using ME-ID 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 Microsoft Entra ID 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

History

  • 2024-07-05 Updated to .NET 8, V4 isolated Azure functions

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 EntraIDJwtBearerValidation class uses the Microsoft Entra ID 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 Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;

namespace FunctionIdentityUserAccess;

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

    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 EntraIDJwtBearerValidation(IConfiguration configuration, ILoggerFactory loggerFactory)
    {
        _configuration = configuration;
        _log = loggerFactory.CreateLogger<EntraIDJwtBearerValidation>();

        _tenantId = _configuration["AzureAd:TenantId"];
        _audience = _configuration["AzureAd:ClientId"];
        _instance = _configuration["AzureAd:Instance"];

        if (_tenantId == null || _audience == null || _instance == null)
        {
            throw new ArgumentException("missing API configuration");
        }

        _wellKnownEndpoint = $"{_instance}{_tenantId}/v2.0/.well-known/openid-configuration";
    }

    public async Task<TokenValidationResult?> 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 JsonWebTokenHandler
        {
            MapInboundClaims = false
        };

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

        try
        {
            var tokenValidationResult = await tokenValidator.ValidateTokenAsync(accessToken, validationParameters);

            if (tokenValidationResult.IsValid && IsScopeValid(_requiredScope, tokenValidationResult.ClaimsIdentity))
            {
                return tokenValidationResult;
            }

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

    public string GetPreferredUserName(ClaimsIdentity claimsIdentity)
    {
        var preferredUsername = string.Empty;
        if (claimsIdentity != null)
        {
            var preferred_username = claimsIdentity.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}", _wellKnownEndpoint);
        _configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
             _wellKnownEndpoint, new OpenIdConnectConfigurationRetriever());

        return await _configurationManager.GetConfigurationAsync();
    }

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

        var scopeClaim = claimsIdentity.HasClaim(x => x.Type == "scp")
            ? claimsIdentity.Claims.First(x => x.Type == "scp").Value
            : string.Empty;

        // fallback for MS mapping
        if (string.IsNullOrEmpty(scopeClaim))
        {
            scopeClaim = claimsIdentity.HasClaim(x => x.Type == scopeType)
            ? claimsIdentity.Claims.First(x => x.Type == scopeType).Value
            : string.Empty;
        }

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

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

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

The project file is setup to use a v4 isolated Azure function and .NET 8.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <AzureFunctionsVersion>v4</AzureFunctionsVersion>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <UserSecretsId>02f65b59-4cc1-43a6-85cd-a209e60e17c3</UserSecretsId>
  </PropertyGroup>
  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.22.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.2.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="1.3.2" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.17.2" />
    <PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.22.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="1.2.0" />

    <PackageReference Include="Azure.Identity" Version="1.12.0" />
    <PackageReference Include="Azure.Security.KeyVault.Certificates" Version="4.6.0" />
    <PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.3.1" />

    <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
    <PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.0" />

    <PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="7.6.2" />
    <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.6.2" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>
  <ItemGroup>
    <Using Include="System.Threading.ExecutionContext" Alias="ExecutionContext" />
  </ItemGroup>
</Project>

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

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
  },
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "98328d53-55ec-4f14-8407-0ca5ff2f2d20" // Audience 
  }
}

The Azure function RandomString can use the EntraIDJwtBearerValidation 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.

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using System.Security.Cryptography;
using System.Text.Encodings.Web;

namespace FunctionIdentityUserAccess;

public class RandomStringFunction
{
    private readonly ILogger<RandomStringFunction> _logger;
    private readonly EntraIDJwtBearerValidation _meIdJwtBearerValidation;

    public RandomStringFunction(ILogger<RandomStringFunction> logger,
        EntraIDJwtBearerValidation meIdJwtBearerValidation)
    {
        _logger = logger;
        _meIdJwtBearerValidation = meIdJwtBearerValidation;
    }

    [Function("RandomString")]
    public async Task<IActionResult> RandomString([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)]
        HttpRequest req)
    {
        try
        {
            _logger.LogInformation("C# HTTP trigger RandomStringAuthLevelAnonymous processed a request.");

            var tokenValidationResult = await _meIdJwtBearerValidation.ValidateTokenAsync(req.Headers["Authorization"]);

            if (tokenValidationResult == null)
            {
                return new UnauthorizedResult();
            }

            var preferredUsername = _meIdJwtBearerValidation.GetPreferredUserName(tokenValidationResult.ClaimsIdentity);
            var claimsName = $"Bearer token claim preferred_username: {preferredUsername}";

            return new OkObjectResult($"{claimsName} {GetEncodedRandomString()}");
        }
        catch (Exception ex)
        {
            return new OkObjectResult($"{ex.Message}");
        }
    }

    private string GetEncodedRandomString()
    {
        var base64 = Convert.ToBase64String(GenerateRandomBytes(100));
        return HtmlEncoder.Default.Encode(base64);
    }

    private byte[] GenerateRandomBytes(int length)
    {
        return RandomNumberGenerator.GetBytes(length);
    }
}

Azure App Registrations

Azure App Registrations is used to setup the Microsoft Entra ID configuration is described in this blog.

Login and use an ASP.NET Core API with ME-ID 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 Microsoft Entra ID 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.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.UI;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient();

builder.Services.AddOptions();

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

builder.Services.AddDistributedMemoryCache();
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();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

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

app.MapRazorPages();
app.MapControllers();

app.Run();

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

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Web;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;

namespace WebIdentityUserAccess.Pages;

public class IndexModel : PageModel
{
    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 provide a way to access functions from a SPA application or a downstream API. 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 Microsoft Entra ID 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

10 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's avatar
    Paul Saxton · · Reply

    Hello

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

    Paul

    1. damienbod's avatar

      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's avatar
        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. TD's avatar

    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: […]

  6. Unknown's avatar

    […] Securing Azure Functions using ME-ID JWT Bearer token authentication for user access tokens […]

  7. Unknown's avatar

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

  8. […] I drew significant inspiration from Damien Bowden‘s work on this topic, especially his detailed post on Securing Azure Functions with JWT bearer tokens from Microsoft Entra ID […]

Leave a reply to damienbod Cancel reply

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