ππΌ ππΌππΌππΌππΌThe code for this article is on github.
Update 1: Use the code on github, the functionality referenced below is now part of Microsoft.Identity.Web. (See PR)
Update 2: You can now generate this code easily using built in templates as indicated here.
dotnet new func2 --auth SingleOrg
dotnet new func2 --auth IndividualB2C
dotnet new func2 --auth SingleOrg -calls-graph
dotnet new func2 --auth SingleOrg -calls-webapi
dotnet new func2
Azure Functions are great! They offer the simplicity of not having to worry about so much of the infrastructure.
Things become interesting when you start dealing with authentication.
The simplest way is to use key based authentication, where you pass a long weird string in front of a function, and the function will accept or reject your call. Keys are, well not the best. If they is compromised, you won't even know it is compromised. Also frequently it is passed in the query string.
We all know, modern authentication is where it is! We want to tie Azure AD authentication with Azure functions.
So, we have EasyAuth. This is pretty easy to setup. Point click, done. But sometimes feels a bit blackboxy. Maybe I want to roll up my sleeves, and integrate custom middleware.
Okay, you can integrate MSAL with Azure Functions. Or you can assign a managed identity to an Azure function and call an MSAL secured Azure function that exposes an API.
But recently, Microsoft GA'd a pretty awesome nuget package called Microsoft.Identity.Web. Why I like Microsoft.Identity.Web is because it makes addressing common identity scenarios dead easy. In fact, Microsoft has published a long list of samples covering both AAD and AADB2C for pretty much anything you care for.
So, why should functions not take advantage of this? In this article, I'll talk about how you can integrate Azure functions with Microsoft.Identity.Web, and I'll use dependency injection in Azure Functions to do so.
First, create a new Azure functions project. You can use Visual studio, Visual Studio for Mac, or Azure functions command line tools to do so. I'll call mine "SampleFunc".
Second, Add two nuget packages,
Microsoft.Azure.Functions.Extensions
Microsoft.Identity.Web
Third, author an extension method that authenticates a user, and sets httpContext.User to the authenticated user principal if authentication succeeds. This is as below,
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace SampleFunc
{
public static class AzureFunctionsAuthenticationHttpContextExtension
{
/// <summary>
/// Enables Bearer authentication for an API for use in Azure Functions.
/// </summary>
/// <param name="httpContext">The current HTTP Context, such as req.HttpContext.</param>
/// <returns>A task indicating success or failure. In case of failure <see cref="Microsoft.Identity.Web.UnauthorizedObjectResult"/>.</returns>
public static async Task<(bool, IActionResult?)> AuthenticateAzureFunctionAsync(
this HttpContext httpContext)
{
if (httpContext == null)
{
throw new ArgumentNullException("Parameter httpContext cannot be null");
}
AuthenticateResult? result =
await httpContext.AuthenticateAsync("Bearer").ConfigureAwait(false);
if (result.Succeeded)
{
httpContext.User = result.Principal;
return (true, null);
}
else
{
return (false, new UnauthorizedObjectResult(new ProblemDetails
{
Title = "Authorization failed.",
Detail = result.Failure?.Message,
}));
}
}
}
}
Fourth, we need to add a Startup.cs class where we specify how exactly we want Microsoft.Identity.Web wired up. Here is the code for it. As you can see, this is pretty much identical to what you'd do in a web application. Need to use B2C? This is where your changes will go.
using System;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Identity.Web;
using Microsoft.IdentityModel.Logging;
[assembly: FunctionsStartup(typeof(SampleFunc.Startup))]
namespace SampleFunc
{
public class Startup : FunctionsStartup
{
public Startup()
{
}
public override void Configure(IFunctionsHostBuilder builder)
{
var configuration = builder.GetContext().Configuration;
builder.Services.AddAuthentication(sharedOptions =>
{
sharedOptions.DefaultScheme = "Bearer";
sharedOptions.DefaultChallengeScheme = "Bearer";
})
.AddMicrosoftIdentityWebApi(configuration)
.EnableTokenAcquisitionToCallDownstreamApi()
.AddInMemoryTokenCaches();
}
}
}
Finally, we make some changes to our function class itself.
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Web;
namespace SampleFunc
{
public class SampleFunc
{
private readonly ITokenAcquisition _tokenAcquisition;
public SampleFunc(ITokenAcquisition tokenAcquisition)
{
_tokenAcquisition = tokenAcquisition;
}
[FunctionName("SampleFunc")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,
ILogger log)
{
log.LogInformation("C# HTTP trigger function processed a request.");
var (authenticationStatus, authenticationResponse) =
await req.HttpContext.AuthenticateAzureFunctionAsync();
if (!authenticationStatus) return authenticationResponse;
var token = await _tokenAcquisition.GetAccessTokenForAppAsync("https://graph.microsoft.com/.default" );
string name = req.HttpContext.User.Identity.IsAuthenticated ? req.HttpContext.User.Identity.Name : null;
string responseMessage = string.IsNullOrEmpty(name)
? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
: $"Hello, {name}. This HTTP triggered function executed successfully.";
return new OkObjectResult(responseMessage);
}
}
}
The part where we are doing authentication is this,
var (authenticationStatus, authenticationResponse) =
await req.HttpContext.AuthenticateFunctionAsync("Bearer");
if (!authenticationStatus) return authenticationResponse;
What I am doing after that is simply getting a token under application permissions to call Graph. Not necessary for this example, but feel free to change that to GetAccessTokenForUserAsynch and try and get a delegated permissions token. Remember, your incoming token must also then have a user identity.
Finally, in our local.settings.json, add the following bits,
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet",
"AzureAd:Instance": "https://login.microsoftonline.com/",
"AzureAd:Domain": "<tenantname>.onmicrosoft.com",
"AzureAd:TenantId": "<guid>",
"AzureAd:ClientId": "<guid>",
"AzureAd:ClientSecret": "<string>",
"AzureAd:AllowWebApiToBeAuthorizedByACL": true
}
}
Of specific mention here is,
AzureAd:AllowWebApiToBeAuthorizedByACL
Microsoft.Identity.Web by default requires AppRole assigned for client credentials, secure by default. So I have to specify here that I won't be using an AppRole.
A word about app registration, this app is going to expose an API. Ensure that you go to the "Expose an API" section and expose an AppID URI, typically api://guid.. and if you intend to use AppOnlyPermissions, ensure that you grant this app permissions to whatever token you are asking for in the below line .. .
var token = await _tokenAcquisition.GetAccessTokenForAppAsync("https://graph.microsoft.com/.default" );
That's it. Build & Deploy.
Test it out
You can test this locally or deployed to Azure.
First, Get an access token, the simplest is client credentials flow, here is how you get the token,
curl --location --request POST 'https://login.microsoftonline.com/<tenantid>/oauth2/v2.0/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=<guid>' \
--data-urlencode 'scope=api://<guid>/.default' \
--data-urlencode 'client_secret=..' \
--data-urlencode 'grant_type=client_credentials'
Second, Call this API with the access token,
curl --location --request GET 'http://localhost:7071/api/SampleFunc' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer ...'
Tadah!! This should successfully call your function.
Here is the code in github for fun'n'learn.
Other community posts interesting to this topic,
- https://hajekj.net/2020/12/12/microsoft-identity-web-and-azure-functions/
- https://damienbod.com/2020/09/24/securing-azure-functions-using-azure-ad-jwt-bearer-token-authentication-for-user-access-tokens/
- https://cmatskas.com/create-an-azure-ad-protected-api-that-calls-into-cosmosdb-with-azure-functions-and-net-core-3-1/
- https://blog.wille-zone.de/post/secure-azure-functions-with-jwt-token/#secure-azure-functions-with-jwt-access-tokens
- https://github.com/AspNetMonsters/AzureFunctions.OidcAuthentication https://hexmaster.nl/posts/az-func-jwt-validator-binding/