Secure Azure Function with Jwt OAUTH2 Token

In the previous post, Business Process Orchestration with Azure Function, we talked about consuming series of Azure Functions to automate business process. All that was good but the options to secure azure functions are not flexible enough for enterprise applications. So, I am going to cover how to secure Azure Functions with custom authentication and authorization using Jwt token issued by an Authorization or OAUTH2 Server. Source code of this post can be found at GitHub.

Azure Function App gives you a set of fixed host keys or function keys and you can manage them at the azure portal. Yes, you may be able to create keys for each client but client can share the keys with anyone! You can renew or revoke the function keys but this kind of security model does not work for most enterprise applications. There is no provision for claim based authentication and role based authorization like the ones (Authorization Filters) we can implement at the Asp.Net MVC5 API. HttpTrigger gives you 4 options for AuthorizationLevel- Anonymous, Function, User, System and Admin. You can read more about Authorization keys at Microsoft documentation. Function, Admin and System authorization level are key based, and they don’t provide desired level of security. At the time of this post, AuthorizationLevel User and Anonymous are same. So, we are going to use Anonymous (no security) authorization level at HttpTrigger Function but we would implement custom authentication/authorization on our own.

In my use case, Angular5, azure.aspnet4you.com, application would be calling Azure Function and I have no way to hide host/function keys in browser! Fortunately, Angular5 app is already enabled to receive Jwt token as part of user authentication. I can simply pass the Jwt token available at the Angular5 app to Azure Function. All we have to do is create a handler to validate the Jwt token. Let’s jump on the codes!

QueueOrder (HttpTrigger Azure Function):


using System.Linq;
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Queue;
using Newtonsoft.Json;
using System.Web.Http;
using AzureFunctionApps.Security;
using System.Security.Claims;

namespace AzureFunctionApps
{
/// <summary>
/// QueueOrder: Insert a POCO order object to Azure Queue Storage. Parameter order object must be defined as HttpTrigger.
/// This is needed for the object to be deserialized automatically by Azure Function. Req object should not be defined at HttpTrigger.
/// https://docs.microsoft.com/en-us/azure/storage/queues/storage-dotnet-how-to-use-queues
/// </summary>
public static class QueueOrder
{
[FunctionName("QueueOrder")]
public static async Task<HttpResponseMessage> Run(HttpRequestMessage req,
[HttpTrigger(AuthorizationLevel.User, "post", Route = null)] POCOOrder order, TraceWriter log)
{
ClaimsPrincipal claimsPrincipal;
CustomValidator customValidator = new CustomValidator(log);
if ((claimsPrincipal = customValidator.ValidateToken(req.Headers.Authorization)) == null)
{
return new HttpResponseMessage(HttpStatusCode.Unauthorized)
{
Content = new StringContent("Invalid Token!", Encoding.UTF8, "application/json")
};
}

string[] allowedRoles = GetEnvironmentVariable("AllowedRoles").Split(new char[] { '|' }, StringSplitOptions.RemoveEmptyEntries);

if(customValidator.IsInRole(claimsPrincipal, allowedRoles) == false)
{
return new HttpResponseMessage(HttpStatusCode.Unauthorized)
{
Content = new StringContent("Invalid Roles!", Encoding.UTF8, "application/json")
};
}

string msg = $"Hello " + order.CustomerName + "! Your order for " + order.ProductName + " with quantity of " + order.OrderCount + " has been received. You will receive email at " + order.CustomerEmail + " as soon as order is shipped.";
log.Info(msg);

var connectionString = GetEnvironmentVariable("AzureWebJobsStorage");

// Retrieve storage account from connection string.
CloudStorageAccount storageAccount = CloudStorageAccount.Parse(connectionString);

// Create the queue client.
CloudQueueClient queueClient = storageAccount.CreateCloudQueueClient();

// Retrieve a reference to a container.
CloudQueue queue = queueClient.GetQueueReference("orderqueue");

// Create the queue if it doesn't already exist
queue.CreateIfNotExists();

// Serialize POCO Order
var orderData = JsonConvert.SerializeObject(order, Formatting.Indented);

// Create a message and add it to the queue.
CloudQueueMessage message = new CloudQueueMessage(orderData);
queue.AddMessage(message);

// Send an acknowledgement to client app
return await Task.Run(() =>
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonConvert.SerializeObject(msg, Formatting.Indented), Encoding.UTF8, "application/json")
};
});
}

private static string GetEnvironmentVariable(string name)
{
return System.Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.Process);
}
}

/// <summary>
/// This is just a simple/sample object class to demonstrate intake of parameter as POCO object.
/// </summary>
public class POCOOrder
{
public string CustomerName { get; set; }
public string CustomerEmail { get; set; }
public string ProductName { get; set; }
public int OrderCount { get; set; }
}
}

CustomValidator:


using Microsoft.Azure.WebJobs.Host;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;

namespace AzureFunctionApps.Security
{
public class CustomValidator
{
private readonly TraceWriter _logger = null;
public CustomValidator() { }

public CustomValidator(TraceWriter logger)
{
_logger = logger;
}

public ClaimsPrincipal ValidateToken(AuthenticationHeaderValue value)
{
ClaimsPrincipal claimsPrincipal = null;

try
{
TokenValidationParameters tokenValidationParameters = null;
string AllowedIssuers = Environment.GetEnvironmentVariable(GlobalConstants.AllowedIssuers, EnvironmentVariableTarget.Process);
string AllowedAudiences = Environment.GetEnvironmentVariable(GlobalConstants.AllowedAudiences, EnvironmentVariableTarget.Process);

string[] audiences = AllowedAudiences.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
string[] issuers = AllowedIssuers.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
string audienceSecreteKey = Environment.GetEnvironmentVariable(GlobalConstants.JwtTopSecrete512, EnvironmentVariableTarget.Process);

string audienceSecreteBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(audienceSecreteKey));
byte[] audienceSecrete = Convert.FromBase64String(audienceSecreteBase64);

tokenValidationParameters = new TokenValidationParameters()
{
IssuerSigningKey = new SymmetricSecurityKey(audienceSecrete),
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidAudiences = audiences,
ValidIssuers = issuers,
ValidateActor = true,
IssuerSigningKeyResolver = CustomSigningResolver
};

SecurityToken validatedToken = null;
CustomJwtSecurityTokenHandler handler = new CustomJwtSecurityTokenHandler();

claimsPrincipal = handler.ValidateToken(value.Parameter, tokenValidationParameters, out validatedToken);
}
catch(Exception ex)
{
if(_logger !=null)
{
_logger.Error(ex.Message);
_logger.Error(ex.StackTrace);
}
}

return claimsPrincipal;
}

/// <summary>
/// IsInRole can be combined with ValidateToken but we are keeping them separate for the demo.
/// Also, in real world scenario, allowedRoles should be cached at the caller to max performance.
/// </summary>
/// <param name="claimsPrincipal"></param>
/// <param name="allowedRoles"></param>
/// <returns></returns>
public bool IsInRole(ClaimsPrincipal claimsPrincipal, string[] allowedRoles)
{
bool amIInRole = false;

// Make a copy of the claims before entring into the loop- to gaurantee concurrency.
Claim[] claimsInClaimsPrincipal = claimsPrincipal.Claims.ToArray();

foreach (Claim claim in claimsInClaimsPrincipal)
{
foreach(string role in allowedRoles)
{
if(role == claim.Value)
{
amIInRole = true;
_logger.Info($"Claim matched: Claim Type:{claim.Type}, Value: {claim.Value}, Issuer: {claim.Issuer}");
break;
}
}

if(amIInRole == true)
{
// Already in role, no need to loop!
break;
}
}

return amIInRole;
}

private static IEnumerable<SecurityKey> CustomSigningResolver(string token, SecurityToken securityToken, string kid, TokenValidationParameters validationParameters)
{
SecurityKey securityKey = validationParameters.IssuerSigningKey;

List<SecurityKey> list = new List<SecurityKey>();
list.Add(securityKey);

return list.AsEnumerable();
}
}
}

CustomJwtSecurityTokenHandler:


using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;

namespace AzureFunctionApps.Security
{
public class CustomJwtSecurityTokenHandler : JwtSecurityTokenHandler
{
public override ClaimsPrincipal ValidateToken(string securityToken, TokenValidationParameters validationParameters, out SecurityToken validatedToken)
{
//TODO:Check the token against cache and/or with issuer.
return BaseValidateToken(securityToken, validationParameters, out validatedToken);
}

public ClaimsPrincipal BaseValidateToken(string securityToken, TokenValidationParameters validationParameters, out SecurityToken validatedToken)
{
ClaimsPrincipal claimsPrincipal = base.ValidateToken(securityToken, validationParameters, out validatedToken);

return claimsPrincipal;
}
}
}

We are going to use Postman (for testing and debugging locally) and pass the Jwt token (copied from azure.aspnet4you.com) to QueueOrder function. It works!

Now, let’s modify the Jwt token at Postman and see what happen! We get 401 status code with a message- Invalid Token!

Okay, we know our custom authentication works as long as valid jwt token is presented at the function. What about the role based authorization? To keep the solution simple, I am using allowed roles from app settings config but we can make it dynamic and read the roles from database. We would use local.settings.json to store settings for local debugging but those values must be present in App Settings in azure portal.

local.settings.json:

Sensitive data has been removed from this screenshot.  We are going to allow the caller to execute the function if Jwt token contains one of the roles present in ClaimsPrincipal. If none, caller will get 401 status code with a message- Invalid Roles!

Changed the values of AllowedRoles to something different to test role based authorization. “AllowedRoles”: “Prodip.Kumar2@Outlook.com|azure2.aspnet4you.com”

We are passing a valid jwt token from Postman but we received 401 status code with a message- Invalid Roles! This is because we modified the allowed roles in the config.

Summary:

We can secure our Azure Functions using authorization level anonymous at the HttpTrigger and implementing our custom validator. Validator can read token of any format from any header key as we wish. Ideally, we would use caching at the server side to minimize the performance loss due to validation work but we left the task out to keep the demo simple.

Another important part that I did not like is- I am doing the validation inside the function itself. This is prone to mistakes- what if developer forgets to add the validation codes in a new function? We have to find a way to fix this problem with a design pattern and not leaving the control in the hands of developers (I am one of them, if that makes you feel better!). We can move the validation logic to a shared class to keep the function body cleaner but potential for human error will remain. Hopefully, Microsoft will update authorization level User and let us inject custom handler to validate token outside the function body.

I liked the post, Considerations for Hardening API’s Built with Azure API Management + Azure Functions, by michaelstephensonuk but it would make the solution over complicated and it would increase the cost of infrastructure resources.

Acknowledgements

 

Leave a Reply