This is the complete solution I ended up using to validate Plaid webhooks, which includes decoding. I'm using Going.Plaid
library to handle getting the JWK. My solution doesn't do any caching (something recommended by Plaid).
I hope this helps someone else, because it took me a little bit to get it configured correctly as my familiarity with JWT is limited.
public async Task<JsonWebKey> GetVerificationKeyAsync(string kid)
{
var plaidClient = new PlaidClient(Settings.Environment, Settings.ClientId, Settings.Secret);
var result = await plaidClient.WebhookVerificationKeyGetAsync(new Going.Plaid.WebhookVerificationKey.WebhookVerificationKeyGetRequest { KeyId = kid });
return new JsonWebKey {
Kid = kid,
Alg = result.Key.Alg,
Crv = result.Key.Crv,
Kty = result.Key.Kty,
Use = result.Key.Use,
X = result.Key.X,
Y = result.Key.Y
};
}
public async Task ValidateWebhookAsync(IDictionary<string, StringValues> headers, string body)
{
var signedJwt = headers.TryGetValue("plaid-verification", out var token) ? token.FirstOrDefault().ToString() : (string)null;
if (string.IsNullOrWhiteSpace(signedJwt))
{
throw new UnauthorizedWebhookRequest("Missing Plaid Verification Header");
}
Logger.LogInformation("Signed JWT: " + signedJwt);
var handler = new JwtSecurityTokenHandler();
var decodedToken = handler.ReadJwtToken(signedJwt);
// validate algorithm
if(decodedToken.Header.Alg != "ES256")
{
throw new UnauthorizedWebhookRequest("Invalid algorithm: " + decodedToken.Header.Alg);
}
// validate issued time
if(decodedToken.IssuedAt.AddMinutes(5) < DateTime.UtcNow)
{
throw new UnauthorizedWebhookRequest("Expired token: " + decodedToken.IssuedAt);
}
// Get the decoder
JsonWebKey decoderKey;
try
{
decoderKey = await GetVerificationKeyAsync(decodedToken.Header.Kid);
}
catch (Exception ex)
{
throw new UnauthorizedWebhookRequest("Unable to get plaid verification key for kid: " + decodedToken.Header.Kid, ex);
}
// Validate the token using decoder
ClaimsPrincipal principal;
try
{
principal = handler.ValidateToken(signedJwt, new TokenValidationParameters
{
IssuerSigningKey = decoderKey,
ValidateLifetime = false,
ValidateAudience = false,
ValidateIssuer = false,
ValidateActor = false,
}, out var validToken);
}
catch (Exception ex)
{
throw new UnauthorizedWebhookRequest("Unable to validate token", ex);
}
// Compare Hash
var requestBodySha256 = principal.FindFirstValue("request_body_sha256");
var bodySha256 = ComputeSha256Hash(body);
if(requestBodySha256 != bodySha256)
{
Logger.LogInformation("Request Sha256: \n" + requestBodySha256);
Logger.LogInformation("Computed Sha256: \n" + bodySha256);
throw new UnauthorizedWebhookRequest("Hash does not match");
}
}