Skip to main content

dotnet Lambda Authorizer with 3rd party Identity Provider

Intro #

Event Driven Architecture (EDA) is a hot topic of conversation at the moment, and while the pattern is not entirely new, David Boyne has done a great job summarizing the patterns in a visual way on the Serverless Land website.

As our teams are migrating their workloads from on premise to AWS, we are leveraging this migration opportunity to evaluate application architecture and make adjustments to align those architectures to cloud native patterns. This way the applications can take full advantage of native cloud services in the future. One of the patterns we are seeing emerge with our migration is the need for our teams to utilize API Gateway within their architectures. In their current form, those APIs are generally deployed on EC2 instances, but as we move our architecture patterns forward, those APIs are moving to API Gateway and Lambda functions. On premise, all requests flow through a proxy where requests are evaluated for Authentication (AuthN) and Authorization (AuthZ) before they are handed off to the target API route. This shift to cloud native and serverless reveals an interesting challenge as the singular proxy can no longer be inserted into the request flow. When working with API Gateway and Lambda, the architecture needs to leverage a Lambda authorizer. As you can see from the diagram, the authorizer is attached to the API Gateway and is executed prior to the function invocation.

Lambda Authorizer Workflow

Many of the available Lambda authorizer examples do a great job explaining what they are and how they work especially with AWS Cognito (Amazon’s Identity Provider - IdP), but there are a sparse few (if any) detailed dotnet examples and little information on how to implement with a 3rd party IdP. Many of the dotnet examples cover how to implement with ASP.NET, and while you can run the ASP.NET pipeline in a lambda function as an application, that pipeline is a bit heavy for a Lambda authorizer.

Vision #

Since we are working in an enterprise with many applications, application teams, and a central IdP, the vision is to have a single authorizer function in an AWS account have the capability to authorize multiple backend APIs with multiple client applications. This requires some interesting considerations when architecting the solution, specifically around what configuration data is needed to support token validation and how and where to store it.

Implementation #

Now that authorizers are generally understood and the vision for the solution has been defined, diving into the details will help illustrate how the vision was achieved.

Authorize Function #

Authorize is the primary function controlling the flow of the authorization process. First, the input token is validated to ensure it isn’t null or an empty string. Next, the TokenHandlerService is called to get the tokenValidationSettings. The keyId and the clientId are retrieved from the incoming token’s header.

Note: We are overloading the typ header value here on purpose to store our OAuth client id. At this point, we have not yet decrypted the JWT payload and we need the client id to retrieve the proper signing key from our IdP dynamically.

The signing key is retrieved using the combination of the keyId, clientId, and the tokenValidationSettings.Issuer values.

Once all of the appropriate variables are configured, the JWT can be validated. If the validation is successful a policy (per the authorization workflow diagram above) is generated.

  public AuthPolicy Authorize(TokenAuthorizerContext input, ILambdaContext context)
  {
    try
    {
      var token = ValidateAuthorizationToken(input.AuthorizationToken);
      var tokenValidationSettings = _tokenHandlerService.GetTokenValidationSettings();
      var keyId = _tokenHandlerService.GetKeyIdFromHeader(token);
      var clientId = _tokenHandlerService.GetTypFromHeader(token);
      var key = GetKey(keyId, clientId, tokenValidationSettings.Issuer);
      var jwtSecurityToken = ValidateToken(token, tokenValidationSettings, key, clientId);
      if (jwtSecurityToken == null)
        throw new NullReferenceException("Token is null");
      // if the token is valid, a policy must be generated which will allow or deny access to the client

      return GeneratePolicy(input, jwtSecurityToken);
    }
    catch (Exception ex)
    {
      // log the exception and return a 401
      Logger.LogError(ex.ToString());
      throw new UnauthorizedException(ex);
    }
  }

If any part of the process throws an exception we want to make sure we handle it properly. To make trouble shooting easier, it is first logged and then wrapped with an UnauthorizedException. Throwing the UnauthorizedException to API Gateway is an acceptable way to tell API Gateway there was an issue without a policy. This exception communicates the Authorizer was unsuccessful and to stop processing the request.

Token Handler Service #

The TokenHandlerService is a wrapper class centralizing features required for interacting with the token. The GetKeyIdFromHeader and the GetTypFromHeader are both wrapper methods for accessing properties of the token. The GetTokenValidationSettings method retrieves values from Secrets Manager by looking for the secret name in the Lambda runtime environment. Next, we use the secretId in our request to Secrets Manager via our internal Secrets Manager nuget package to retrieve the values.

  public string GetKeyIdFromHeader(string authorizationToken)
  {
    var jwt = _jwtSecurityTokenHandler.ReadJwtToken(authorizationToken);
    return jwt.Header.Kid;
  }

  public string GetTypFromHeader(string authorizationToken)
  {
    var jwt = _jwtSecurityTokenHandler.ReadJwtToken(authorizationToken);
    return jwt.Header.Typ;
  }

  public TokenValidationSettings GetTokenValidationSettings()
  {
    var secretId = Environment.GetEnvironmentVariable("SECRET_NAME");
    var tokenValidationSettings = _secretManager.GetSecretAsync<TokenValidationSettings>(secretId).Result;
    if (tokenValidationSettings == null)
      throw new NullReferenceException("TokenValidationSettings is null");
    if (!tokenValidationSettings.IsValid)
      throw new NullReferenceException("TokenValidationSettings are not valid");
    return tokenValidationSettings;
  }

Token Validation #

Token validation in dotnet is straightforward, especially if symmetric keys are provided by the token issuer. We started with the symmetric key stored in the secret to make sure the workflow was correct and the framework was solid. As our token validation proficiency and more was learned about the capabilities of the IdP, we moved to an asymmetric signing key stored in the IdP and retrieved dynamically. More on this below when discussing the IdpService.

JwtSecrets Class #

TokenValidationSettings is a strongly typed class used to manage secrets within the project. Each public property corresponds to a name value pair stored in Secrets Manager. There is also validity checking to ensure the values were read in properly prior to use.

public class TokenValidationSettings : ISecret
{
    public string Issuer { get; set; }

    public List<string> Audiences { get; set; }

    public Dictionary<string, string> DecryptionKeys {get; set; }

    public bool IsValid => !string.IsNullOrEmpty(Issuer) &&
                           Audiences.Any();

    public string GetDecryptionKeyByClientId(string clientId)
    {
      DecryptionKeys.TryGetValue(clientId, out var decryptionKey);
      return decryptionKey ?? string.Empty;
    }
}

The json in Secrets Manager looks like this:

{
  "Issuer": "https://issuer.com",
  "Audiences": ["aud1", "aud2"],
  "DecryptionKeys": {
    "ClientId1": "xxxxxxxxxxxxxxxxxxxxx",
    "ClientId2": "yyyyyyyyyyyyyyyyyyyyy"
  }
}

Token Validation Parameters #

Taking a look at how the TokenValidationParameters are configured in the GetTokenValidationParameters function, it accepts TokenValidationSettings (the secret we retrieved earlier), the asymmetric signing key (jsonWebKey), and the clientId. The jsonWebKey is a Base64 representation of the public key retrieved from the IdP and is converted into an RSASecurityKey in the BuildAssymetricKey method. To facilitate decryption of the token, a SymmetricSecurityKey is created by by extracting it from the DecryptionKey dictionary using the clientId. All of these combine to facilitate the validation and decryption of the incoming JWT token.

 private TokenValidationParameters GetTokenValidationParameters(TokenValidationSettings tokenValidationSettings, JsonWebKey jsonWebKey, string clientId)
  {
    var signingKey = _idpService.BuildAsymmetricKey(jsonWebKey);
    var decryptionKey = new SymmetricSecurityKey(Convert.FromBase64String(tokenValidationSettings.GetDecryptionKeyByClientId(clientId)));
    var tokenValidationParameters = new TokenValidationParameters
    {
      ClockSkew = TimeSpan.FromMinutes(2),
      IssuerSigningKey = signingKey,
      RequireExpirationTime = true,
      RequireSignedTokens = true,
      ValidateLifetime = true,
      ValidateAudience = true,
      ValidAudiences = tokenValidationSettings.Audiences,
      ValidateIssuer = true,
      ValidIssuer = tokenValidationSettings.Issuer,
      ValidateIssuerSigningKey = true,
      TokenDecryptionKey = decryptionKey
    };
    return tokenValidationParameters;
  }

Validate Token #

ValidateToken retrieves the tokenValidationParameters and passes it along with the authorization token from the Lambda function call to the _securityTokenValidator service. This service is a wrapper around the Microsoft.IdentityModel.Tokens.SecurityTokenValidator class used to facilitate unit testing. The entire class is listed for clarity.

using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;

namespace MyNamespace;

public class SecurityTokenValidator : ISecurityTokenValidator
{
  private readonly SecurityTokenHandler _securityTokenHandler;

  public SecurityTokenValidator(SecurityTokenHandler securityTokenHandler)
  {
    _securityTokenHandler = securityTokenHandler;
  }

  public JwtSecurityToken ValidateToken(string token, TokenValidationParameters validationParameters)
  {
    _securityTokenHandler.ValidateToken(token, validationParameters, out SecurityToken validatedToken);
    var jwToken = (JwtSecurityToken) validatedToken;
    return jwToken;
  }
}

IdpService #

Initially symmetric keys were used to validate the token signature, but as the project took shape the vision of supporting a multi tenant function exposed a limitation. Security best practices tell us using a single singing key for multiple client/API combinations isn’t optimal. Many of the asymmetric key examples shared on the internet show the use of a physical pem file or an xml file containing the public key material and deploying that file with the function. This also isn’t ideal as embedding the signing key into the function would require a new deployment when the key is rotated. We could store the signing key in the secret like we’ve done with the decryption key, but since it is a public key, we don’t need to keep it secret. What if we could use our IdPs key management capability and retrieve them dynamically for validation? Fortunately our IdP facilitates publishing a JSON Web Key Token Set (JWKS) endpoint which contains the public keys for a given client id.

The IdpService used in the GetTokenValidationParameters methods abstracts this public endpoint and all other interactions with our IdP. GetKeys retrieves the JSON Web Key Set (JWKS) collection from the IdP. Take note of the url (var jwksUrl = $"{baseUrl}/ext/{clientId}/{_jwksEndpoint}";) where we are passing in the clientId. The HttpService is just a wrapper around the Microsoft HttpClient to facilitate unit testing.

Note: This project is also leveraging the recently GA released AWS Lambda Powertools for DotNet logging library.

public JsonWebKeySet? GetKeys(string clientId, string baseUrl)
{
  //use http client to retrieve keys from JWKS endpoint
  var jwksUrl = $"{baseUrl}/ext/{clientId}/{_jwksEndpoint}";
  Logger.LogInformation($"Retrieving keys from: {jwksUrl}");
  using HttpResponseMessage response = _httpService.GetAsync(jwksUrl).Result;
  Logger.LogInformation($"Response status code {response.StatusCode} for {jwksUrl}");
  response.EnsureSuccessStatusCode();
  var responseBody = response.Content.ReadAsStringAsync().Result;
  var keySet = new JsonWebKeySet(responseBody);
  Logger.LogInformation($"Retrieved {keySet.Keys.Count} key(s)");
  return keySet;
}

GetKeyById, wraps the GetKeys method and finds the matching kid in the collection. The kid was obtained from the token header.

public JsonWebKey? GetKeyById(string keyId, string clientId, string baseUrl)
{
  var keySet = GetKeys(clientId, baseUrl);
  if (keySet != null && keySet.Keys.Any())
  {
    return keySet.Keys.ToList().Find(k => k.KeyId == keyId) ?? null;
  }

  return null;
}

BuildAsymmetricKey takes the x5c property from the JsonWebKey and converts it to an RsaSecurityKey.

public RsaSecurityKey BuildAsymmetricKey(JsonWebKey jsonWebKey)
{
  Logger.LogInformation($"Building Asymmetric Key from keyId: {jsonWebKey.KeyId}");
  var certificate = new X509Certificate2(Convert.FromBase64String(jsonWebKey.X5c.First()));
  var publicKey = certificate.GetRSAPublicKey();
  var key = new RsaSecurityKey(publicKey);
  return key;
}

Audience(s) #

So now we’ve covered the shift from a single symmetric key to multiple asymmetric keys for validating client requests, what other features can be implemented to facilitate a multi tenant function to know which APIs are valid? The TokenValidationParameters supports passing in both a single value via the Audience property and a list of values via the Audiences property. By leveraging the Audiences property, the assignment for ValidAudiences = tokenValidationSettings.Audiences makes achieving this goal easy by including the appropriate values ("Audiences":["aud1", "aud2"]) in Secrets Manager.

Token Encryption #

So far we haven’t addressed encrypting the token. Like the singing key, this is a security best practice and fortunately it was straightforward by setting the TokenDecryptionKey property of TokenValidationParameters to the proper key. The DecryptionKeys property was added to our secret as a dictionary where they dictionary key is the cliendId and the value is the Base64 encoded encryption key.

"DecryptionKeys": {
    "ClientId1": "xxxxxxxxxxxxxxxxxxxxx",
    "ClientId2": "yyyyyyyyyyyyyyyyyyyyy"
}

The dictionary gives us the ability to meet our multi tenant goal with the various clients. Since these encryption keys are symmetrical we need to store them in secrets manager for safe keeping.

AWS Authorizer Blueprints and Policy Generation #

Once the token is validated successfully there is a final step to fulfill the Lambda Authorization workflow. API Gateway expects an IAM policy document to be returned from the authorizer and we are leveraging blueprint code to generate the appropriate policy. For this purpose, it is only important whether or not the token is valid and has a particular claim which has some meaning to the applications. In the example code below, Name is used, but this could be any type of identifier (probably a unique key) which validates the request was authenticated by the IdP. If the token is valid, an Allow policy is returned, and if the token is not valid it is a Deny policy. If the authorizer returns an Allow, API Gateway then forwards the request to the underlying system (Lambda function, EC2 instance, etc). The downstream application is then responsible for ensuring the specific request is authorized for the function using a combination of audience, scope, claims, etc.

  private AuthPolicy GeneratePolicy(TokenAuthorizerContext input, JwtSecurityToken jwtSecurityToken)
  {
    var methodArn = ApiGatewayArn.Parse(input.MethodArn);
    var apiOptions = new ApiOptions(methodArn.Region, methodArn.RestApiId, methodArn.Stage);

    var claim = jwtSecurityToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name);

    return claim != null
      ? CreateAllowAuthPolicy(claim.Value, methodArn, apiOptions)
      : CreateDenyAuthPolicy("Unknown User", methodArn, apiOptions);
  }

Wrapping up #

Hopefully this has been a useful read for those seeking some options with custom authorizers in the enterprise. It has been a very interesting exercise and it will surely evolve over time as it is implemented by our various teams. A big thank you to Matt Tebo for his help with setting up the IdP properly and ensuring we have a secure solution. Also to David Wilson for giving this a proof read and some good ideas to aid with clarity.

Resources #

Here are some resources which were helpful when thinking and working through this solution.