Keith
Keith

Reputation: 309

Empty JWK set using Spring Authorization Server as Keycloak IDP

I am building a Spring OAuth2 Authorization Server for users and essentially started by using 1 of the example projects on the official Spring Authorization Server github.

With that, I'm trying to use my Spring Auth. Server as an IDP in Keycloak. I have successfully added it as an IDP and Keycloak redirects me to the Spring Auth Server's login form, but when I login, Keycloak throws the below exception:

2024-08-07 17:57:36,179 WARN  [org.keycloak.keys.infinispan.InfinispanPublicKeyStorageProvider] (executor-thread-11) PublicKey wasn't found in the storage. Requested kid: 'd2ff780e-5b42-4f7a-83dc-e7bd23d838de' . Available kids: '[]'
2024-08-07 17:57:36,180 ERROR [org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider] (executor-thread-11) Failed to make identity provider oauth callback: org.keycloak.broker.provider.IdentityBrokerException: token signature validation failed

However, when I visit the JWK URI of my Spring Auth. Server, it shows the exact kid for which Keycloak is looking for, and it is clearly not an empty set as Keycloak claims, see below:

{
  "keys": [
    {
      "kty": "RSA",
      "e": "AQAB",
      "kid": "d2ff780e-5b42-4f7a-83dc-e7bd23d838de",
      "alg": "RS256",
      "n": "4wURNXcZ7g54Bl92Zw9l8Vr02D1u_e9ddW2E56DmMUfDdKwwGkpf4yyqC4HYl8V9yBLA1XG6VGKSn4b0sdXjJQ7kEBxUdGz-JNNynU_E9bypeJQARxWHV7-U73gsWeXS0HNIsoo6DryZ4d9cDS3wsF_saKzFXWKILGcSjnE5jJHEoqHVyqgQTL17jSbypGJ6y5fSoOLc0USI_XLNqiZw8lwEJzhWIbnigxnbrNo97oEwKzADa98wfsF_Re_NaZ_TIynjoZwZAKVz4bK3ZAulvlUItWhVj0ySGHMh9WcjlZlowZd94eaAkS9J7NHvxiC4gV3Iv0Ij4c_2zq5NhMdWMw"
    }
  ]
}

I HAVE performed authorization_code grant flow with my Spring Auth. Server DIRECTLY (without Keycloak using it as IDP) successfully, using my in memory user. I took the JWT generated by the Spring Auth. Server and validated both the header and the signature using jwt.io. There's nothing wrong with it.

Now that the high level issue has been described, I will post some additional info below:

Here are the openId urls returned by my ".well-known/openid-configuration" endpoint of my Spring Authorization Server (yes, I have https enabled, as Keycloak requires this for IDP's)

{"issuer":"https://auth-server:8443","authorization_endpoint":"https://auth-server:8443/oauth2/authorize","token_endpoint":"https://auth-server:8443/oauth2/token","token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt"],"jwks_uri":"https://auth-server:8443/oauth2/jwks","userinfo_endpoint":"https://auth-server:8443/userinfo","response_types_supported":["code"],"grant_types_supported":["authorization_code","client_credentials","refresh_token"],"revocation_endpoint":"https://auth-server:8443/oauth2/revoke","revocation_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt"],"introspection_endpoint":"https://auth-server:8443/oauth2/introspect","introspection_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"scopes_supported":["openid"]}

And here are some of the configurations in Keycloak including these same URLs, so I don't see how any of the URL's should be incorrect. We can see that RS256 is also the cryptographic signing algorithm selected, which is correct per what my Spring Auth. Server uses to generate the JWT:

enter image description here

And lastly, here are most of my configurations for the Spring Auth server. Here we can further confirm that yes, RS256 is what is used to generate the JWT for users, and, for OAuth2 client connection (such as Keycloak -> Spring Auth Server) the selection in Keycloak IDP settings of "Client Secret sent as Basic Auth" is indeed correct as well, as are the clientId/secret credentials:

 @Bean
        public RegisteredClientRepository registeredClientRepository() {
            
            RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                    .clientId("client1")
                    .clientSecret("{noop}myClientSecretValue")
                    .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                    .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                    .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                    .redirectUri("https://127.0.0.1:8080/login/oauth2/code/users-client-oidc")
                    .redirectUri("https://127.0.0.1:8080/authorized")
                    .redirectUri("http://localhost:8080/realms/ApiRealm/broker/oidc/endpoint") 
                    .scope(OidcScopes.OPENID)
                    .scope("read")
                    //.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                    .build();
            
            return new InMemoryRegisteredClientRepository(registeredClient);
        }


@Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
 
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
                
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(Customizer.withDefaults())
        ;

        
        http.cors(cors -> cors.disable());
        
        
        
        
        http.requiresChannel( channel -> channel.anyRequest().requiresSecure());
        return http.formLogin(Customizer.withDefaults()).build();
    }
    
    @Bean
    public ClientSettings clientSettings() {
        return ClientSettings.builder()
                .requireAuthorizationConsent(false)
                .requireProofKey(false)
                .build();
    }
    
    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
                .issuer("https://auth-server:8443")
                .build();
    }

I have indeed looked at other posts, all that I've found are either unanswered and/or too contextually different. Thanks a lot in advance.

Upvotes: 1

Views: 196

Answers (2)

Keith
Keith

Reputation: 309

As Jareon suggested, the issue was that the JWK Set constructed by Spring Auth Server did not contain the expected "use" property with value of "sig." This property is used to denote that the public key exposed by the JWK URI is to be used to validate the signature of the token corresponding to the same kid (key ID.)

I am sure there are other ways to modify the public key which is returned by the JWK URI for Spring Auth Server, but my solution was simply to implement a custom controller:

@RestController
@RequestMapping(value = "/oauth2/jwks-custom", produces = "application/json")
public class JwkController {

    @GetMapping()
    public ResponseEntity<Map<String, Object>> getJwkSet() {
        System.out.println("Controller invoked");

        // Get the RSA key
        RSAKey rsaKey = AuthorizationServerConfiguration.rsaKey;

        // Prepare the JWK representation as expected by Keycloak
        Map<String, Object> jwk = new HashMap<>();
        jwk.put("kid", rsaKey.getKeyID());
        jwk.put("kty", rsaKey.getKeyType().getValue());
        jwk.put("alg", "RS256"); // Adjust the algorithm based on your setup
        jwk.put("e", Base64.getUrlEncoder().withoutPadding().encodeToString(rsaKey.getPublicExponent().decode()));
        jwk.put("n", Base64.getUrlEncoder().withoutPadding().encodeToString(rsaKey.getModulus().decode()));
        jwk.put("x5c", Collections.singletonList(
                "MIICnzCCAYcCBgGRLav6kTANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDDAhBcGlSZWFsbTAeFw0yNDA4MDcxNjI3MTFaFw0zNDA4MDcxNjI4NTFaMBMxETAPBgNVBAMMCEFwaVJlYWxtMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsW8a9djEz0FnsfG4P/Uc1pNTxZXy0sdZEVHt+KiFxkl33/oy8CeXYCkQUcKs+/YpseMCLb9xJ9Y78wxBfa/a5Z8d5bCKaC6/Uu1QbPttlxT5z7zyMHcFqat1yATEqUIEAaVyEb6qFAzikgWBocGmDCFeIQemLDGMFzOprfp/ZmwfWgANbdH270sooPqptsp/JcK5nVn+oX1Geo2H/ig8YiSZzUCMP8/9k4QoGqeIcL8UPxOM9o1ZHcavB3sA1fKrnizFZEh1/IHrAzktmhKuvzU5wITc26cwrDEsT+FX4sZYMdqgvZZ08dnOPVNYvwXGkQFS/RYfV4RMY3ZNtneAsQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAdTmAL2/n3K3OnVqvTPxmbCJ4F0vwSzL+FfxybBGMJacStIRSfP3B0czYONVJls+1+dz2DSdrluZr/nEs4e+o6jODr8q1V+4r8zzucWhzxrFcWckpiKnccX0IAR10fGHMqvIOWrDbCrG7EHsSLQOijfIFJ9WIYhq7IlXLqjvQSEEVFb3FACcMZN/DCbZBVUL5HDoOs9LSNvGZrmZPILjyspdWmJz4DwHGh7xtcq4RAY6vozDpSLe4Zcpd+EWkxkmZPj3bRIqfnAbV97NFYeq8Ff6nzS2+DqfTbs39HGYutPnQ2RWMhhTqEEEBasAuWbHS3Y/lZYIVsVtTIEj5xQrPb"));
        jwk.put("use", "sig"); // This is what was missing
        
        // Create the final JWK Set response
        Map<String, Object> jwkSet = new HashMap<>();
        jwkSet.put("keys", Collections.singletonList(jwk));

        return ResponseEntity.ok(jwkSet);
    }

And of course, the RSA Key used to generate these values for the JSON in the JWK URI must be the same RSA Key used to generate the JWT issued to authenticated users, IE below:

public class AuthorizationServerConfiguration { 

public static RSAKey rsaKey;

    private static RSAKey generateRsa() throws NoSuchAlgorithmException {
            KeyPair keyPair = generateRsaKey();
            RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
            RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
    
            RSAKey newRsaKey = new RSAKey.Builder(publicKey).algorithm(JWSAlgorithm.RS256).privateKey(privateKey)
                    .keyID(hardcodedKeyProvider()).build();
    
            rsaKey = newRsaKey;
    
            return rsaKey;
        }
    
        private static KeyPair generateRsaKey() throws NoSuchAlgorithmException {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
    
            return keyPairGenerator.generateKeyPair();
        }
}

Upvotes: 1

jareon
jareon

Reputation: 376

Try making sure your key has the use attribute.

{
  "keys": [
    {
      "kty": "RSA",
      "e": "AQAB",
      "kid": "d2ff780e-5b42-4f7a-83dc-e7bd23d838de",
      "alg": "RS256",
      "n": "4wURNXcZ7g54Bl92Zw9l8Vr02D1u_e9ddW2E56DmMUfDdKwwGkpf4yyqC4HYl8V9yBLA1XG6VGKSn4b0sdXjJQ7kEBxUdGz-JNNynU_E9bypeJQARxWHV7-U73gsWeXS0HNIsoo6DryZ4d9cDS3wsF_saKzFXWKILGcSjnE5jJHEoqHVyqgQTL17jSbypGJ6y5fSoOLc0USI_XLNqiZw8lwEJzhWIbnigxnbrNo97oEwKzADa98wfsF_Re_NaZ_TIynjoZwZAKVz4bK3ZAulvlUItWhVj0ySGHMh9WcjlZlowZd94eaAkS9J7NHvxiC4gV3Iv0Ij4c_2zq5NhMdWMw",
      "use": "sig"
    }
  ]
}

Upvotes: 2

Related Questions