diff --git a/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs
new file mode 100644
index 0000000..0fb0a42
--- /dev/null
+++ b/IdentityShroud.Core.Tests/JwtSignatureGeneratorTests.cs
@@ -0,0 +1,83 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using IdentityShroud.Core.Messages;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace IdentityShroud.Core.Tests;
+
+public class JwtSignatureGeneratorTests
+{
+
+ [Fact]
+ public void VerifySignatureValid()
+ {
+ using var rsa = RSA.Create(2048);
+
+ string header = WebEncoders.Base64UrlEncode("fake header"u8.ToArray());
+ string payload = WebEncoders.Base64UrlEncode("fake payload"u8.ToArray());
+ var jwtString = JwtSignatureGenerator.GenerateCompleteJwt(header, payload, rsa);
+
+ Assert.True(ValidateJwtSignature(jwtString, rsa));
+ }
+
+ ///
+ /// This test is to prove our signature verification code is correct. The inputs are
+ /// all from a production keycloak instance.
+ ///
+ [Fact]
+ public void ValidateKeycloakSignature()
+ {
+ string keycloakGeneratedJwt =
+ "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJybVZ3TU5rM0o1WHlmMWhyS3NVbEVYN1BNUm42dlZKY0h3U3FYMUVQRnFJIn0.eyJleHAiOjE3NzEwNTQxMDksImlhdCI6MTc3MTA1MzgwOSwiYXV0aF90aW1lIjoxNzcxMDUzODA4LCJqdGkiOiI5MTEzZjEwNC03YzllLTQzNzItYmU4Yy03NDMwMmI1ZTU1NGUiLCJpc3MiOiJodHRwczovL2lhbS5rYXNzYWNsb3VkLm5sL2F1dGgvcmVhbG1zL21wbHVza2Fzc2EiLCJhdWQiOlsia2Fzc2EtbWFuYWdlbWVudC1zZXJ2aWNlIiwiYXBhY2hlMi1pbnRyYW5ldC1hdXRoIiwiYWNjb3VudCJdLCJzdWIiOiIwOTNjY2YxNS1jNGE5LTRhYjQtOTcxZi1kNWEwMjIzNmQ4NWEiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJkZWFsZXJfc3VwcG9ydCIsInNpZCI6IjRiYjI0OGQ1LWVkNzktNGU0Yy05NWNjLTAwYzgzMzliZmZiMyIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cHM6Ly9tcGx1c2thc3NhLm9ubGluZSIsImh0dHBzOi8vd3d3Lm1wbHVza2Fzc2Euc3VwcG9ydCIsImh0dHBzOi8vbXBsdXNrYXNzYS5zdXBwb3J0IiwiaHR0cDovL2xvY2FsaG9zdDo0MDkwIiwiaHR0cHM6Ly93d3cubXBsdXNrYXNzYS5vbmxpbmUiLCJodHRwOi8vbG9jYWxob3N0IiwiLyoiLCJodHRwOi8vbG9jYWxob3N0OjQyMDAiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtbXBsdXNrYXNzYSIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJkZWFsZXItbWVkZXdlcmtlci1yb2xlIiwibXBsdXNrYXNzYS1tZWRld2Vya2VyLXJvbGUiXX0sInJlc291cmNlX2FjY2VzcyI6eyJhcGFjaGUyLWludHJhbmV0LWF1dGgiOnsicm9sZXMiOlsiaW50cmFuZXQiLCJyZWxlYXNlbm90ZXNfd3JpdGUiXX0sImthc3NhLW1hbmFnZW1lbnQtc2VydmljZSI6eyJyb2xlcyI6WyJwb3NhY2NvdW50X3Bhc3N3b3JkcmVzZXQiLCJkcmFmdF9saWNlbnNlX3dyaXRlIiwibGljZW5zZV9yZWFkIiwia25vd2xlZGdlSXRlbV9yZWFkIiwibWFpbGluZ19yZWFkIiwibXBsdXNhcGlfcmVhZCIsImRhdGFiYXNlX3VzZXJfd3JpdGUiLCJlbnZpcm9ubWVudF93cml0ZSIsImdrc19hdXRoY29kZV9yZWFkIiwiZW1wbG95ZWVfcmVhZCIsImRhdGFiYXNlX3VzZXJfcmVhZCIsImFwaWFjY291bnRfcGFzc3dvcmRyZXNldCIsIm1wbHVzYXBpX3dyaXRlIiwiZW52aXJvbm1lbnRfcmVhZCIsImtub3dsZWRnZUl0ZW1fd3JpdGUiLCJkYXRhYmFzZV91c2VyX3Bhc3N3b3JkX3JlYWQiLCJsaWNlbnNlX3dyaXRlIiwiY3VzdG9tZXJfd3JpdGUiLCJkZWFsZXJfcmVhZCIsImVtcGxveWVlX3dyaXRlIiwiZGF0YWJhc2VfY29uZmlndXJhdGlvbl93cml0ZSIsInJlbGF0aW9uc19yZWFkIiwiZGF0YWJhc2VfdXNlcl9wYXNzd29yZF9tcGx1c19lbmNyeXB0ZWRfcmVhZCIsImRyYWZ0X2xpY2Vuc2VfcmVhZCIsImRhdGFiYXNlX2NvbmZpZ3VyYXRpb25fcmVhZCJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQga21zIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVhbGVySWQiOjEsIm5hbWUiOiJFZWxrZSBLbGVpbiIsInByZWZlcnJlZF91c2VybmFtZSI6ImVlbGtlQGJvbHQubmwiLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJFZWxrZSIsImZhbWlseV9uYW1lIjoiS2xlaW4iLCJlbWFpbCI6ImVlbGtlQGJvbHQubmwiLCJlbXBsb3llZU51bWJlciI6NTR9.SHjVTWsFwiaKTxBX-0GZM1pK8rOodkYnEu_QJ4dlPpozai9j3RRJK3DswsuEbJC8PdQXI4-AI0-5JGBQi2gDXdFSVHhAblnmjva0sWCaY7lG2ASa65UKM_4RzH-6nvQ9EiZXdANzsWkLG350l-dLiqdt--Lpjpw2huK_GKAx20SKfauKBmm990rHzrl0Uii3wQ3fPHlAJ_8-WSnSBquOH8xsYJHa1LOsc2WqbEDnMA4hRnGvCoubwhkOANfWSx0OCwSIKBddrcts64ZAxFhmilZXGzWMqDkblY2fDU8_jrlysgYsymQlOVwwg7V5Ps-DJkGXWvmpncKfyYd3Vuwusg";
+ string keycloakKeySet = """
+ {
+ "keys": [
+ {
+ "kid": "rmVwMNk3J5Xyf1hrKsUlEX7PMRn6vVJcHwSqX1EPFqI",
+ "kty": "RSA",
+ "alg": "RS256",
+ "use": "sig",
+ "n": "pYbLAeOLDEwzL4tEwuE2LfisOBXoQqWA9RdP3ph6muwF1ErfhiBSIB2JETKf7F1OsiF1_qnuh4uDfn0TO8bK3lSfHTlIHWShwaJ_UegS9ylobfIYXJsz0xmJK5ToFaSYa72D_Dyln7ROxudu8-zc70sz7bUKQ0_ktWRsiu76vY6Kr9-18PgaooPmb2QP8lS8IZEv-gW5SLqoMc1DfD8lsih1sdnQ8W65cBsNnenkWc97AF9cMR6rdD2tZfLAxEHKYaohAL9EsQsLic3P2f2UaqRTAOvgqyYE5hyJROt7Pyeyi8YSy7zXD12h2mc0mrSoA-u7s_GrOLcLoLLgEnRRVw",
+ "e": "AQAB",
+ "x5c": [
+ "MIICozCCAYsCBgFwfLC07DANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDAptcGx1c2thc3NhMB4XDTIwMDIyNTE0MTAyMFoXDTMwMDIyNTE0MTIwMFowFTETMBEGA1UEAwwKbXBsdXNrYXNzYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKWGywHjiwxMMy+LRMLhNi34rDgV6EKlgPUXT96YeprsBdRK34YgUiAdiREyn+xdTrIhdf6p7oeLg359EzvGyt5Unx05SB1kocGif1HoEvcpaG3yGFybM9MZiSuU6BWkmGu9g/w8pZ+0TsbnbvPs3O9LM+21CkNP5LVkbIru+r2Oiq/ftfD4GqKD5m9kD/JUvCGRL/oFuUi6qDHNQ3w/JbIodbHZ0PFuuXAbDZ3p5FnPewBfXDEeq3Q9rWXywMRBymGqIQC/RLELC4nNz9n9lGqkUwDr4KsmBOYciUTrez8nsovGEsu81w9dodpnNJq0qAPru7Pxqzi3C6Cy4BJ0UVcCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAYcJVYv8HzZQIMrqhIyu7EVihPAx0w9NaZ1xzB9qCHrwie6ZLQdnMm8l0IdehyYuY+0HK7FjC8dAcT4nklOQDg4iCp7ZrM7vFNP+60pR0i7aIbf0cFXy9VTOPvUsXmu+p1LqRQLRJD0BjO29gupTe68KyTtyuX5A7JfmCq84j5i45Md8A9MAMZWnXMSHiaZLtlOS/4t4cdc371uq9fH7SKusnvUY0d14+BzcrHi/eurhKUUjJZ1xclUE2trOXFrE78fSMUmeGbIlpAV0MtrJW7OmXmaHH8Q1wTm78RH/dn4EmWSoFcigdVcDge941/MT2soDSIGLUnYiYrW8d6HE2Lg=="
+ ],
+ "x5t": "rj9_q26MIdowvyJJbyHySeUl1y8",
+ "x5t#S256": "KNyQ8ngE925F__ZPJm-wCNUnGBJQGJbZGGjlCvmwBkM"
+ }
+ ]
+ }
+ """;
+
+ JsonWebKeySet keySet = JsonSerializer.Deserialize(keycloakKeySet)!;
+ using RSA publicKey = LoadFromJwk(keySet.Keys[0]);
+
+ Assert.True(ValidateJwtSignature(keycloakGeneratedJwt, publicKey));
+ }
+
+ private bool ValidateJwtSignature(string jwtString, RSA publicKey)
+ {
+ int lastDotIndex = jwtString.LastIndexOf('.');
+
+ return publicKey.VerifyData(
+ Encoding.UTF8.GetBytes(jwtString, 0, lastDotIndex),
+ WebEncoders.Base64UrlDecode(jwtString, lastDotIndex + 1, jwtString.Length - (lastDotIndex + 1)),
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+ }
+
+ private static RSA LoadFromJwk(JsonWebKey jwk)
+ {
+ var rsa = RSA.Create();
+ var parameters = new RSAParameters
+ {
+ Modulus = WebEncoders.Base64UrlDecode(jwk.Modulus),
+ Exponent = WebEncoders.Base64UrlDecode(jwk.Exponent)
+ };
+
+ rsa.ImportParameters(parameters);
+ return rsa;
+ }
+
+}
\ No newline at end of file
diff --git a/IdentityShroud.Core/Security/JwtSignatureGenerator.cs b/IdentityShroud.Core/Security/JwtSignatureGenerator.cs
index 11f8dc2..e22cfca 100644
--- a/IdentityShroud.Core/Security/JwtSignatureGenerator.cs
+++ b/IdentityShroud.Core/Security/JwtSignatureGenerator.cs
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Core;
-public class JwtSignatureGenerator
+public static class JwtSignatureGenerator
{
///
/// Generates a JWT signature using RS256 algorithm