This post shows how to implement OAuth security for an Azure Function using user-access JWT Bearer tokens created using Azure AD 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
Blogs in the series
- Securing Azure Functions using API Keys
- Securing Azure Functions using Certificate authentication
- Securing Azure Functions using an Azure Virtual Network
- Securing Azure Key Vault inside a VNET and using from an Azure Function
- Securing Azure Functions using Azure AD JWT Bearer token authentication for user access tokens
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 AzureADJwtBearerValidation class uses the Azure AD 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 System; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; namespace FunctionIdentityUserAccess { public class AzureADJwtBearerValidation { private IConfiguration _configuration; private ILogger _log; private const string scopeType = @"http://schemas.microsoft.com/identity/claims/scope"; private ConfigurationManager<OpenIdConnectConfiguration> _configurationManager; private ClaimsPrincipal _claimsPrincipal; 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 AzureADJwtBearerValidation(IConfiguration configuration, ILoggerFactory loggerFactory) { _configuration = configuration; _log = loggerFactory.CreateLogger<AzureADJwtBearerValidation>(); _tenantId = _configuration["AzureAd:TenantId"]; _audience = _configuration["AzureAd:ClientId"]; _instance = _configuration["AzureAd:Instance"]; _wellKnownEndpoint = $"{_instance}{_tenantId}/v2.0/.well-known/openid-configuration"; } public async Task<ClaimsPrincipal> 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 JwtSecurityTokenHandler(); var validationParameters = new TokenValidationParameters { RequireSignedTokens = true, ValidAudience = _audience, ValidateAudience = true, ValidateIssuer = true, ValidateIssuerSigningKey = true, ValidateLifetime = true, IssuerSigningKeys = oidcWellknownEndpoints.SigningKeys, ValidIssuer = oidcWellknownEndpoints.Issuer }; try { SecurityToken securityToken; _claimsPrincipal = tokenValidator.ValidateToken(accessToken, validationParameters, out securityToken); if (IsScopeValid(_requiredScope)) { return _claimsPrincipal; } return null; } catch (Exception ex) { _log.LogError(ex.ToString()); } return null; } public string GetPreferredUserName() { string preferredUsername = string.Empty; var preferred_username = _claimsPrincipal.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}"); _configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>( _wellKnownEndpoint, new OpenIdConnectConfigurationRetriever()); return await _configurationManager.GetConfigurationAsync(); } private bool IsScopeValid(string scopeName) { if (_claimsPrincipal == null) { _log.LogWarning($"Scope invalid {scopeName}"); return false; } var scopeClaim = _claimsPrincipal.HasClaim(x => x.Type == scopeType) ? _claimsPrincipal.Claims.First(x => x.Type == scopeType).Value : string.Empty; if (string.IsNullOrEmpty(scopeClaim)) { _log.LogWarning($"Scope invalid {scopeName}"); return false; } if (!scopeClaim.Equals(scopeName, StringComparison.OrdinalIgnoreCase)) { _log.LogWarning($"Scope invalid {scopeName}"); return false; } _log.LogDebug($"Scope valid {scopeName}"); return true; } } }
When using Microsoft.IdentityModel.Protocols.OpenIdConnect you need to add the _FunctionsSkipCleanOutput to your Azure function project file, otherwise you will have runtime exceptions. System.IdentityModel.Tokens.Jwt is also required.
<PropertyGroup> <TargetFramework>netcoreapp3.1</TargetFramework> <AzureFunctionsVersion>v3</AzureFunctionsVersion> <_FunctionsSkipCleanOutput>true</_FunctionsSkipCleanOutput> <LangVersion>latest</LangVersion> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="3.0.9" /> <PackageReference Include="Microsoft.Azure.Functions.Extensions" Version="1.1.0" /> <PackageReference Include="Microsoft.Azure.WebJobs.Extensions.Storage" Version="4.0.2" /> <PackageReference Include="Microsoft.Azure.KeyVault" Version="3.0.5" /> <PackageReference Include="Microsoft.Extensions.Configuration.AzureKeyVault" Version="3.1.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="3.1.8" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.8" /> <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="3.1.8" /> <PackageReference Include="System.Configuration.ConfigurationManager" Version="4.7.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.7.1" /> <PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="6.7.1" /> </ItemGroup>
The AzureADJwtBearerValidation service is added to the DI in the startup class.
[assembly: FunctionsStartup(typeof(Startup))] namespace FunctionIdentityUserAccess { public class Startup : FunctionsStartup { public override void Configure(IFunctionsHostBuilder builder) { builder.Services.AddScoped<AzureADJwtBearerValidation>(); }
Add the AzureAd configurations to the local settings as required and also to the Azure Functions configurations in the portal.
"AzureAd": { "Instance": "https://login.microsoftonline.com/", "TenantId": "[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App registrations' blade and use the GUID in any of the URLs), e.g. da41245a5-11b3-996c-00a8-4d99re19f292]", "ClientId": "[Enter the Client Id (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]" }
The Azure function RandomString can use the AzureADJwtBearerValidation 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.
namespace FunctionIdentityUserAccess { public class RandomStringFunction { private readonly ILogger _log; private readonly AzureADJwtBearerValidation _azureADJwtBearerValidation; public RandomStringFunction(ILoggerFactory loggerFactory, AzureADJwtBearerValidation azureADJwtBearerValidation) { _log = loggerFactory.CreateLogger<RandomStringFunction>();; _azureADJwtBearerValidation = azureADJwtBearerValidation; } [FunctionName("RandomString")] public async Task<IActionResult> RandomString( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req) { try { _log.LogInformation("C# HTTP trigger RandomStringAuthLevelAnonymous processed a request."); ClaimsPrincipal principal; // This can be used for any claims if ((principal = await _azureADJwtBearerValidation.ValidateTokenAsync(req.Headers["Authorization"])) == null) { return new UnauthorizedResult(); } return new OkObjectResult($"Bearer token claim preferred_username: {_azureADJwtBearerValidation.GetPreferredUserName()} {GetEncodedRandomString()}"); } catch (Exception ex) { return new OkObjectResult($"{ex.Message}"); } }
Azure App Registrations
Azure App Registrations is used to setup the Azure AD configuration is described in this blog.
Login and use an ASP.NET Core API with Azure AD 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 Azure AD 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.
public void ConfigureServices(IServiceCollection services) { services.AddHttpClient(); services.AddOptions(); string[] initialScopes = Configuration.GetValue<string> ("CallApi:ScopeForAccessToken")?.Split(' '); services.AddMicrosoftIdentityWebAppAuthentication(Configuration) .EnableTokenAcquisitionToCallDownstreamApi(initialScopes) .AddInMemoryTokenCaches(); services.AddRazorPages().AddMvcOptions(options => { var policy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build(); options.Filters.Add(new AuthorizeFilter(policy)); }).AddMicrosoftIdentityUI(); } public void Configure(IApplicationBuilder app) { app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapRazorPages(); }); }
The OnGetAsync method of a Razor page calls the Azure Function API using the access token from the AAD.
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 be the way to access functions from a SPA application. 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 Azure AD and no other authentication systems. In-memory cache is a problem when using this together with Web APP and APIs.
Links
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://github.com/AzureAD/microsoft-identity-web
https://github.com/Azure-Samples/active-directory-aspnetcore-webapp-openidconnect-v2