dotnet Lambda Authorizer with 3rd party Identity Provider
Table of Contents
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.
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.