Note: This is using an early version of the Azure Function worker host which supports .NET 5.0 using out-of-process. Check out the repo here: Azure/azure-functions-dotnet-worker: Azure Functions out-of-process .NET language worker (github.com)

As this is quite new, I expect the missing support for Microsoft.Identity.Web or other ways of supporting JWT Bearer authentication will be added soonly-ish.

Anyway! As an exercise, here's one way of (not ready for production use) securing your Azure Functions which also works for self-hosted functions.

FrodeHus/functions-dotnet-worker-auth
Contribute to FrodeHus/functions-dotnet-worker-auth development by creating an account on GitHub.
Update: You can now easily add this to your project by using a NuGet-package:
dotnet add package FrodeHus.Azure.FunctionsNETWorker.Authentication --version 1.0.0
Go to NuGet Gallery | FrodeHus.Azure.FunctionsNETWorker.Authentication 1.0.0 for instructions how to use it.

Creating the project

Follow this guide to install the correct version and then run

func init SecureFunctions --worker-runtime dotnetIsolated

Open up your favorite IDE and let's start hackin' away at this.

Register an Azure Active Directory Application

In order to add authenticate with Azure AD, we first need to register our application.

Follow these instructions to do so: Quickstart: Register an app in the Microsoft identity platform | Microsoft Docs

Take note of Application (client) id and Tenant id - we will need this later to validate our users.

Adding Azure AD configuration

Let's keep this config out of the functions config so we'll add our trusty ol' appsettings.json

Create a file named appsettings.json in the root of your project and fill it with these values:

{
    "AzureAd": {
        "Instance": "https://login.microsoftonline.com/",
        "TenantId": "your tenant id from the app registration",
        "ClientId": "your application or client id from the app registration"
    }
}

Now we need to make sure this is loaded by our worker.

Open your SecureFunctions.csproj file and add the following to make sure appsettings.json is published with your function:

  <ItemGroup>
    <None Update="appsettings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

Next, we need to make sure it's being read and included in the configuration so we can read it from code later.

Open Program.cs and it will probably look like this:

public static void Main()
{
    var host = new HostBuilder()
        .ConfigureFunctionsWorkerDefaults()
        .Build();

    host.Run();
}

Change it so it looks like this:

 public static void Main()
{
    var host = new HostBuilder()
        .ConfigureAppConfiguration(c =>
        {
            c.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
            c.Build();
        })
        .ConfigureFunctionsWorkerDefaults()
        .Build();

    host.Run();
}

We'll finish up configuration by making it available through dependency injection.

That means we need a class to hold our values, so let's create it!

Create a new file called AzureAdConfig.cs like this:

namespace SecureFunctions
{
    public class AzureAdConfig
    {
        public string ClientId { get; set; }
        public string TenantId { get; set; }
        public string Instance { get; set; }
    }
}

Then register it with the dependency injection container by calling .ConfigureServices(..) on the HostBuilder in Program.cs. Your code should now look something like this:

public static void Main()
{
    var host = new HostBuilder()
        .ConfigureAppConfiguration(c =>
        {
            c.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
            c.Build();
        })
        .ConfigureServices((b, s) =>
        {
            s.AddOptions();
            s.Configure<AzureAdConfig>(b.Configuration.GetSection("AzureAd"));
        })
        .ConfigureFunctionsWorkerDefaults()
        .Build();

    host.Run();
}

Whenever a class asks for IOptions<AzureAdConfig> they will now receive our config - sweet!

Creating the authentication middleware

To create a custom middleware, we need to implement IFunctionsWorkerMiddleware and a single method public Task Invoke(FunctionContext context, FunctionExecutionDelegate next) which will be called as a part of the pipeline when functions are being executed.

Let's just dive into it!

Create AuthenticationMiddleware.cs and populate it with:

using System.Threading.Tasks;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;

namespace SecureFunctions
{
    public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
    {
        public Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
        {
            throw new System.NotImplementedException();
        }
    }
}

That's the skeleton of our middleware - it will run (and crash, due to the thrown exception in there).

Get our Azure AD config

We'll use dependency injection to get our configuration, as mentioned above.

Create a constructor which takes IOptions<AzureAdConfig> as parameter.

private readonly AzureAdConfig _config;
public AuthenticationMiddleware(IOptions<AzureAdConfig> config)
{
    _config = config.Value;
}

Retrieve Azure Active Directory OpenIdConnect configuration

In order to validate the incoming JWT tokens (as issued by Azure Active Directory), we need to know how to validate them and which keys Azure uses.

First of all we need a NuGet-package to help us - Microsoft.Identity.Web

Run dotnet add package Microsoft.Identity.Web to install that.

Let's create a method to retrieve the configuration:

private async Task ConfigureValidation()
{
    var configManager = new ConfigurationManager<OpenIdConnectConfiguration>($"{_config.Instance}common/v2.0/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever());
    var oidcConfig = await configManager.GetConfigurationAsync().ConfigureAwait(false);
}

If you want to see the values quick and easy - just open https://login.microsoftonline.com/<insert your tenant id here>/v2.0/.well-known/openid-configuration in your browser.

Configure validation

Now that we have the configuration, we need to configure how to validate our tokens.

Still in ConfigureValidation() we need use our AzureAdConfig to specify which audiences we allow. Typically this is ClientId (or Application id).

Tweak this to your liking and needs.

We pass this and the issuer keys to a TokenValidationParameters class and return it to the caller:

return new TokenValidationParameters
{
    ValidAudiences = new string[] { _config.ClientId },
    ValidateAudience = true,
    ValidateIssuer = false,
    IssuerSigningKeys = oidcConfig.SigningKeys,
    ValidateLifetime = true
};

Validating the token

We can now validate the token by creating the following method and then call it from Invoke(..):

We return both the validated token and the resulting ClaimsPrincipal just because we might need both - who knows.

private async Task<(JwtSecurityToken, ClaimsPrincipal)> Validate(string token)
{
    var validationParameters = await ConfigureValidation().ConfigureAwait(false);

    var tokenHandler = new JwtSecurityTokenHandler();
    var result = tokenHandler.ValidateToken(token, validationParameters, out var jwt);

    return (jwt as JwtSecurityToken, result);
}

Of course, this retrieves the configuration from Azure Active Directory every time and sets up validation so improvements can be done by keeping a reference of the validation parameters instance for a while (but not forever, as the signing keys gets rotated).

Putting it all together

We're finally ready to get rid of the NotImplementedException and put some functionality in Invoke(..) so that our middleware will actually perform the validation.

We first need to retrieve the Authorization header value from our context.

var headerData = context.BindingContext.BindingData["headers"] as string;
var headers = JsonSerializer.Deserialize<Dictionary<string, string>>(headerData);
var authorization = headers["Authorization"];
var bearerHeader = AuthenticationHeaderValue.Parse(authorization);
var token = bearerHeader.Parameter;

Now we can finally call our validation method:

try
{
    var (t, principal) = await Validate(token).ConfigureAwait(false);
}
catch (Exception ex)
{
    logger.LogError(ex, "Failed to validate token");
}

From here it's kind of up to you what you wish to do with the validation result - I haven't figured out a way to add the ClaimsPrincipal to the identities in the function context, yet.

Let me know if you know how!

My workaround so far is to add a few things to the context so I can verify in the functions themselves:

context.Items.Add("roles", principal.FindAll(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList());
context.Items.Add("name", principal.Identity.Name);
context.Items.Add("isAuthenticated", principal.Identity.IsAuthenticated);

Don't forget to keep the pipeline going by invoking the next middleware:

await next(context).ConfigureAwait(false);

The entire Invoke(..) method should look like:

public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
    var logger = context.GetLogger<AuthenticationMiddleware>();
    var headerData = context.BindingContext.BindingData["headers"] as string;
    var headers = JsonSerializer.Deserialize<Dictionary<string, string>>(headerData);
    var authorization = headers["Authorization"];
    var bearerHeader = AuthenticationHeaderValue.Parse(authorization);
    var token = bearerHeader.Parameter;
    try
    {
        var (t, principal) = await Validate(token).ConfigureAwait(false);
        context.Items.Add("roles", principal.FindAll(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList());
        context.Items.Add("name", principal.Identity.Name);
        context.Items.Add("isAuthenticated", principal.Identity.IsAuthenticated);
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Failed to validate token");
    }

    await next(context).ConfigureAwait(false);
}

Now we have to register our custom middleware with the pipeline so go to your Program.cs and change .ConfigureFunctionsWorkerDefaults() to

.ConfigureFunctionsWorkerDefaults(app => app.UseMiddleware<AuthenticationMiddleware>())

Verifying our user in the function

For convenience, I added some extension methods in a FunctionContextExtensions.cs class (based on the extra data I added above):

public static class FunctionContextExtensions
{
    public static bool IsInRole(this FunctionContext context, string role)
    {
        if (!context.Items.ContainsKey("roles"))
        {
            return false;
        }
        if (context.Items["roles"] is not List<string> roles)
        {
            return false;
        }

        return roles.Any(r => r == role);
    }

    public static bool IsAuthenticated(this FunctionContext context)
    {
        if (!context.Items.ContainsKey("isAuthenticated"))
        {
            return false;
        }
        if (context.Items["isAuthenticated"] is not bool authenticated)
        {
            return false;
        }

        return authenticated;
    }
}

So, now I can finally verify in my function that the user has the correct access:

Create a new function: func new SecureFunc --template "HttpTrigger"

And add your checks:

if (!executionContext.IsAuthenticated())
{
    return req.CreateResponse(HttpStatusCode.Unauthorized);
}

if (!executionContext.IsInRole("My.AppRole"))
{
    return req.CreateResponse(HttpStatusCode.Forbidden);
}

Whew! We made it!

If this was a bit all over the place you can check out the finished project here: FrodeHus/functions-dotnet-worker-auth (github.com)