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.
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)