New .NET 5 Azure Functions have the option of running in an isolated process. As you can see from the roadmap, this model is planned to be the default in .NET 7. The previous model of running through a class library has some downsides, such as conflicts with assembly versions. And while that is great, I'm really excited about being able to write middleware. Previously there wasn't a clean way of implementing cross-cutting concerns and you often end up with Functions where you just have to remember to call the AuthorizeUser method at the start each time. With middleware, we can implement things like authentication cleanly across all Functions.

In this article we will take a look at middleware in .NET isolated process Azure Functions, and implement Azure AD JWT authentication and authorization using them. You can find the sample application in GitHub. Do be cautious as it uses some reflection magic to set the HTTP response status code. I'm hoping that won't be necessary later, it's definitely the most brittle part.

Our end goal is to be able to define authorization rules like this:

public static class TestFunctions
{
    [Authorize(
        Scopes = new[] { "access_as_user" },
        UserRoles = new[] { "admin" })]
    public static HttpResponseData OnlyAdmins(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req,
        FunctionContext executionContext)
    {
    }
}

We will build this [Authorize] attribute that allows us to declare what permissions are needed to access each function. Authentication and authorization will be executed on all requests in a way that makes it impossible to forget to add it, by using middleware.

Middleware in Azure Functions

If you are familiar with ASP.NET Core, you already know what middleware are and why they can be very useful. Essentially, they allow you to wrap code around all of your functions, current and future.

With middleware we can implement cross-cutting concerns such as authentication, authorization, and logging. We can run code before/after a function executes. We could also decide not to call the function at all.

A middleware is defined as a class:

public class SampleMiddleware : IFunctionsWorkerMiddleware
{
    public async Task Invoke(
        FunctionContext context, FunctionExecutionDelegate next)
    {
        DoStuffBeforeFunction();

        await next(context);

        DoStuffAfterFunction();
    }
}

You can also inject services to the middleware through the constructor, though they should be singletons. Same as in ASP.NET Core, middleware are instantiated only once when the first request is received. In addition, if you need transient/scoped services, you can get them from the FunctionContext object's InstanceServices property.

Middleware are registered in the Program class:

public class Program
{
    public static void Main()
    {
        var host = new HostBuilder()
            .ConfigureFunctionsWorkerDefaults((context, builder) =>
            {
                builder.UseMiddleware<SampleMiddleware>();
            })
            .Build();

        host.Run();
    }
}

Authentication middleware

You can find the complete code for the authentication middleware in the sample here. For this sample, I wanted the authentication middleware to:

  • Check the request for an access token and validate it
  • If the token is valid, assign the user to the request and continue the request
  • If the token is not found or is invalid, return a 401 Unauthorized status code

We can start with an empty middleware class:

public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
{
    public async Task Invoke(
        FunctionContext context, FunctionExecutionDelegate next)
    {
        await next(context);
    }
}

When our Functions are called with a JSON Web Token, an Authorization header will be set on the request.

So how do we get the token? Like this:

private static bool TryGetTokenFromHeaders(FunctionContext context, out string token)
{
    token = null;
    // HTTP headers are in the binding context as a JSON object
    // The first checks ensure that we have the JSON string
    if (!context.BindingContext.BindingData.TryGetValue("Headers", out var headersObj))
    {
        return false;
    }

    if (headersObj is not string headersStr)
    {
        return false;
    }

    // Deserialize headers from JSON
    var headers = JsonSerializer.Deserialize<Dictionary<string, string>>(headersStr);
    var normalizedKeyHeaders = headers.ToDictionary(h => h.Key.ToLowerInvariant(), h => h.Value);
    if (!normalizedKeyHeaders.TryGetValue("authorization", out var authHeaderValue))
    {
        // No Authorization header present
        return false;
    }

    if (!authHeaderValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
    {
        // Scheme is not Bearer
        return false;
    }

    token = authHeaderValue.Substring("Bearer ".Length).Trim();
    return true;
}

It's not the easiest thing. Because middleware in Azure Functions can wrap all kinds of Functions (queues, timers etc.), they offer a very generic model to request data. The "binding data" dictionary contains the headers as a JSON string. From there we can deserialize them and get the access token from the Authorization header.

Alright, now we might have failed to get the token. Maybe the request didn't include it. What now? We check the result and if it failed, set the unauthorized status code and stop processing:

if (!TryGetTokenFromHeaders(context, out var token))
{
    // Unable to get token from headers
    context.SetHttpResponseStatusCode(HttpStatusCode.Unauthorized);
    return;
}

SetHttpResponseStatusCode is an extension method I made. I really wish there was an easier and less brittle way of doing this, but:

public static void SetHttpResponseStatusCode(
    this FunctionContext context,
    HttpStatusCode statusCode)
{
    var coreAssembly = Assembly.Load("Microsoft.Azure.Functions.Worker.Core");
    var featureInterfaceName = "Microsoft.Azure.Functions.Worker.Context.Features.IFunctionBindingsFeature";
    var featureInterfaceType = coreAssembly.GetType(featureInterfaceName);
    var bindingsFeature = context.Features.Single(
        f => f.Key.FullName == featureInterfaceType.FullName).Value;
    var invocationResultProp = featureInterfaceType.GetProperty("InvocationResult");

    var grpcAssembly = Assembly.Load("Microsoft.Azure.Functions.Worker.Grpc");
    var responseDataType = grpcAssembly.GetType("Microsoft.Azure.Functions.Worker.GrpcHttpResponseData");
    var responseData = Activator.CreateInstance(responseDataType, context, statusCode);

    invocationResultProp.SetMethod.Invoke(bindingsFeature, new object[] { responseData });
}

Yeah. I don't exactly enjoy writing reflection code, but I could not find another way to achieve this. I looked at what the Functions SDK does to set the status code, and essentially you need to set a response data object in the InvocationResult property. Too bad all the relevant classes are marked internal. The extension method the SDK uses to access the feature is also internal.

I certainly hope that a better way becomes available. In the actual Function code, you get an HttpRequestData object, and you can use that to create the response. That class is abstract and is currently implemented as GrpcHttpRequestData, which is also internal. We could also get the request here in the extension method and use it to create the response as normal, but that would actually involve more reflection code.

Now that we have a utility for setting the status code, we can implement the rest of the middleware. First we need a token validator and OpenID Connect metadata retriever:

public AuthenticationMiddleware(IConfiguration configuration)
{
    var authority = configuration["AuthenticationAuthority"];
    var audience = configuration["AuthenticationClientId"];
    _tokenValidator = new JwtSecurityTokenHandler();
    _tokenValidationParameters = new TokenValidationParameters
    {
        ValidAudience = audience
    };
    _configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
        $"{authority}/.well-known/openid-configuration",
        new OpenIdConnectConfigurationRetriever());
}

The purpose of the ConfigurationManager is to load the configuration metadata from the "well known" endpoint. This includes things like the valid issuer, signing keys etc. If you want to see what one of these looks like, here is the Azure AD common endpoint metadata.

We can then use the token validator and configuration manager to validate the token:

if (!_tokenValidator.CanReadToken(token))
{
    // Token is malformed
    context.SetHttpResponseStatusCode(HttpStatusCode.Unauthorized);
    return;
}

// Get OpenID Connect metadata
var validationParameters = _tokenValidationParameters.Clone();
var openIdConfig = await _configurationManager.GetConfigurationAsync(default);
validationParameters.ValidIssuer = openIdConfig.Issuer;
validationParameters.IssuerSigningKeys = openIdConfig.SigningKeys;

try
{
    // Validate token
    var principal = _tokenValidator.ValidateToken(
            token, validationParameters, out _);

    // Set principal + token in Features collection
    // They can be accessed from here later in the call chain
    context.Features.Set(new JwtPrincipalFeature(principal, token));

    await next(context);
}
catch (SecurityTokenException)
{
    // Token is not valid (expired etc.)
    context.SetHttpResponseStatusCode(HttpStatusCode.Unauthorized);
    return;
}

The above code was based on the code that the ASP.NET Core JWT handler uses.

Note we set a feature object within the try block after we have validated the token. This is a way to pass the claims principal to the authorization middleware. The Function itself can also access the principal from there. The access token that was used is also included in case the Function needs to call APIs using the on-behalf-of flow.

Authorization middleware

You can find the complete code for the authorization middleware in the sample here. For this sample, I wanted the authorization middleware to:

  • Check what scopes / user roles / app roles the Function requires
  • If the claims principal meets the requirements, continue the request
  • If the claims principal does not have the required claims, return a 403 Forbidden status code

For the sample, I wanted to implement something similar to ASP.NET Core's [Authorize] attribute. This is what I came up with:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthorizeAttribute : Attribute
{
    /// <summary>
    /// Defines which scopes (aka delegated permissions)
    /// are accepted. In this sample these
    /// must be combined with <see cref="UserRoles"/>.
    /// </summary>
    public string[] Scopes { get; set; } = Array.Empty<string>();
    /// <summary>
    /// Defines which user roles are accpeted.
    /// Must be combined with <see cref="Scopes"/>.
    /// </summary>
    public string[] UserRoles { get; set; } = Array.Empty<string>();
    /// <summary>
    /// Defines which app roles (aka application permissions)
    /// are accepted.
    /// </summary>
    public string[] AppRoles { get; set; } = Array.Empty<string>();
}

This allows the developer to declare what scopes/user roles/app roles are allowed for a particular Function and/or all Functions in a class:

[Authorize(
    Scopes = new[] { "access_as_user" },
    UserRoles = new[] { "user", "admin" })]
public static class TestFunctions
{
    public static HttpResponseData UsersAndAdmins(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req,
        FunctionContext executionContext)
    {
    }

    [Authorize(
        Scopes = new[] { "access_as_user" },
        UserRoles = new[] { "admin" })]
    public static HttpResponseData OnlyAdmins(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req,
        FunctionContext executionContext)
    {
    }
}

Here in this example the first Function would be callable by apps with the "access_as_user" scope calling the Function on behalf of a user who has either the "user" or "admin" role. The second Function on the other hand restricts the allowed set further by specifying only the "admin" role is allowed.

Our middleware is quite simple at the high level:

public class AuthorizationMiddleware : IFunctionsWorkerMiddleware
{
    public async Task Invoke(
        FunctionContext context,
        FunctionExecutionDelegate next)
    {
        var principalFeature = context.Features.Get<JwtPrincipalFeature>();
        if (!AuthorizePrincipal(context, principalFeature.Principal))
        {
            context.SetHttpResponseStatusCode(HttpStatusCode.Forbidden);
            return;
        }

        await next(context);
    }
}

We get the claims principal that the authentication middleware stored in the Features collection, run authorization, and reject the request if it fails. When authorizing, we need to handle the two possible cases: scopes and app roles.

private static bool AuthorizePrincipal(FunctionContext context, ClaimsPrincipal principal)
{
    if (principal.HasClaim(c => c.Type == "http://schemas.microsoft.com/identity/claims/scope"))
    {
        // Request made with delegated permissions, check scopes and user roles
        return AuthorizeDelegatedPermissions(context, principal);
    }

    // Request made with application permissions, check app roles
    return AuthorizeApplicationPermissions(context, principal);
}

If there is a scope claim, the call was made on behalf of a user. In that case we need to check scopes and user roles. Otherwise the request was made as an application and so we check app roles.

Checking app roles is pretty straightforward, we check if the claims principal has one of the accepted roles:

private static bool AuthorizeApplicationPermissions(FunctionContext context, ClaimsPrincipal principal)
{
    var targetMethod = context.GetTargetFunctionMethod();

    var acceptedAppRoles = GetAcceptedAppRoles(targetMethod);
    var appRoles = principal.FindAll(ClaimTypes.Role);
    var appHasAcceptedRole = appRoles.Any(ur => acceptedAppRoles.Contains(ur.Value));
    return appHasAcceptedRole;
}

The check for scopes adds a layer as it must check both scopes and user roles:

private static bool AuthorizeDelegatedPermissions(FunctionContext context, ClaimsPrincipal principal)
{
    var targetMethod = context.GetTargetFunctionMethod();

    var (acceptedScopes, acceptedUserRoles) = GetAcceptedScopesAndUserRoles(targetMethod);

    var userRoles = principal.FindAll(ClaimTypes.Role);
    var userHasAcceptedRole = userRoles.Any(ur => acceptedUserRoles.Contains(ur.Value));

    // Scopes are stored in a single claim, space-separated
    var callerScopes = (principal.FindFirst("http://schemas.microsoft.com/identity/claims/scope")?.Value ?? "")
        .Split(' ', StringSplitOptions.RemoveEmptyEntries);
    var callerHasAcceptedScope = callerScopes.Any(cs => acceptedScopes.Contains(cs));

    // This app requires both a scope and user role
    // when called with scopes, so we check both
    return userHasAcceptedRole && callerHasAcceptedScope;
}

So how are we getting the target method? Well, it's another utility extension method:

public static MethodInfo GetTargetFunctionMethod(this FunctionContext context)
{
    // This contains the fully qualified name of the method
    // E.g. IsolatedFunctionAuth.TestFunctions.ScopesAndAppRoles
    var entryPoint = context.FunctionDefinition.EntryPoint;

    var assemblyPath = context.FunctionDefinition.PathToAssembly;
    var assembly = Assembly.LoadFrom(assemblyPath);
    var typeName = entryPoint.Substring(0, entryPoint.LastIndexOf('.'));
    var type = assembly.GetType(typeName);
    var methodName = entryPoint.Substring(entryPoint.LastIndexOf('.') + 1);
    var method = type.GetMethod(methodName);
    return method;
}

It's a bit more reflection code, though this time it isn't peering into Functions SDK internals. It would be nice if the MethodInfo object was available on the context without having to resort to this.

The two methods used by the AuthorizeX methods look like this:

private static (List<string> scopes, List<string> userRoles) GetAcceptedScopesAndUserRoles(MethodInfo targetMethod)
{
    var attributes = GetCustomAttributesOnClassAndMethod<AuthorizeAttribute>(targetMethod);
    // If scopes A and B are allowed at class level,
    // and scope A is allowed at method level,
    // then only scope A can be allowed.
    // This finds those common scopes and
    // user roles on the attributes.
    var scopes = attributes
        .Select(a => a.Scopes)
        .Aggregate(new List<string>().AsEnumerable(), (result, acceptedScopes) =>
        {
            return result.Intersect(acceptedScopes);
        })
        .ToList();
    var userRoles = attributes
        .Select(a => a.UserRoles)
        .Aggregate(new List<string>().AsEnumerable(), (result, acceptedRoles) =>
        {
            return result.Intersect(acceptedRoles);
        })
        .ToList();
    return (scopes, userRoles);
}

private static List<string> GetAcceptedAppRoles(MethodInfo targetMethod)
{
    var attributes = GetCustomAttributesOnClassAndMethod<AuthorizeAttribute>(targetMethod);
    // Same as above for scopes and user roles,
    // only allow app roles that are common in
    // class and method level attributes.
    return attributes
        .Select(a => a.AppRoles)
        .Aggregate(new List<string>().AsEnumerable(), (result, acceptedRoles) =>
        {
            return result.Intersect(acceptedRoles);
        })
        .ToList();
}

They get the [Authorize] attributes defined on the Function method and the class containing the method, and get the intersection of allowed values. This means only values specified as allowed at both class and method level will be accepted. If the attribute is only defined at one of those levels, then only that one's accepted values apply.

There's one last utility method in the middleware class for getting the attributes:

private static List<T> GetCustomAttributesOnClassAndMethod<T>(MethodInfo targetMethod)
    where T : Attribute
{
    var methodAttributes = targetMethod.GetCustomAttributes<T>();
    var classAttributes = targetMethod.DeclaringType.GetCustomAttributes<T>();
    return methodAttributes.Concat(classAttributes).ToList();
}

Maybe a bit overkill to make it generic? Regardless, it gets the attribute on the method and the class and concatenates the two.

Finishing up

Now to get our app working we need to first register the middleware in the Program class:

public class Program
{
    public static void Main()
    {
        var host = new HostBuilder()
            .ConfigureFunctionsWorkerDefaults((context, builder) =>
            {
                builder.UseMiddleware<AuthenticationMiddleware>();
                builder.UseMiddleware<AuthorizationMiddleware>();
            })
            .Build();

        host.Run();
    }
}

The order is important! Since we need to run authentication before authorization, we must add authentication middleware first.

We will need to register an application in Azure AD to represent the Function app, with all the scopes, user roles and app roles we need. We can then configure authentication through local.settings.json:

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "AuthenticationAuthority": "https://login.microsoftonline.com/your-aad-tenant-id/v2.0",
    "AuthenticationClientId": "your-aad-app-id"
  }
}

And then configure authorization on our Functions:

// If an Authorize attribute is placed at class-level,
// requests to any function within the class
// must pass the authorization checks
[Authorize(
    Scopes = new[] { Scopes.FunctionsAccess },
    UserRoles = new[] { UserRoles.User, UserRoles.Admin },
    AppRoles = new[] { AppRoles.AccessAllFunctions })]
public static class TestFunctions
{
    // This function can be called with both scopes and app roles
    // We don't need another Authorize attribute since it would just
    // contain the same values.
    [Function("ScopesAndAppRoles")]
    public static HttpResponseData ScopesAndAppRoles(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req,
        FunctionContext executionContext)
    {
        return CreateOkTextResponse(
            req,
            "Can be called with scopes or app roles");
    }

    // This function can only be called with scopes
    [Authorize(
        Scopes = new[] { Scopes.FunctionsAccess },
        UserRoles = new[] { UserRoles.User, UserRoles.Admin })]
    [Function("OnlyScopes")]
    public static HttpResponseData OnlyScopes(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req,
        FunctionContext executionContext)
    {
        return CreateOkTextResponse(req, "Can be called with scopes only");
    }

    // This function can only be called with app roles
    [Authorize(AppRoles = new[] { AppRoles.AccessAllFunctions })]
    [Function("OnlyAppRoles")]
    public static HttpResponseData OnlyAppRoles(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req,
        FunctionContext executionContext)
    {
        return CreateOkTextResponse(req, "Can be called with app roles only");
    }

    // This function can only be called with scopes + admin role
    [Authorize(
        Scopes = new[] { Scopes.FunctionsAccess },
        UserRoles = new[] { UserRoles.Admin })]
    [Function("OnlyAdmin")]
    public static HttpResponseData OnlyAdmin(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req,
        FunctionContext executionContext)
    {
        return CreateOkTextResponse(
            req,
            "Can be called with scopes and admin user only");
    }

    private static HttpResponseData CreateOkTextResponse(
        HttpRequestData request,
        string text)
    {
        var response = request.CreateResponse(HttpStatusCode.OK);
        response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
        response.WriteString(text);
        return response;
    }
}

And we are done! Now to test authentication and authorization, you'll need a client application. Some API testers like Postman support OAuth authentication out of the box and can be used to test the different scenarios. I actually have an article on this topic: https://joonasw.net/view/testing-azure-ad-protected-apis-part-2-postman.

Summary

Quite a bit of code was required to make all of this work. But it is really nice to have authentication and authorization handled outside the Functions themselves. A developer cannot forget to apply them to new Functions added to the app. Even if they forgot the attributes entirely, authorization would fail.

If you do use this code in your application, keep in mind that the SetHttpResponseStatusCode extension method is depending on SDK internals which means it can break on even a patch of the SDK.

Hope this was useful, until next time!

Links