javaspring-bootspring-securitykeycloak

Spring Security + Keycloak (with self signed certs) - How do you disable hostname verification?


I'm running Keycloak 24.0.0 with a self signed certificate.

My springboot application authenticates against Keycloak using the client secret authentication method and authorisation code grant type (via spring security 6.2):

    private ClientRegistration keycloakClientRegistration() {
        return ClientRegistration.withRegistrationId("keycloak")
                .clientId(keycloakInitializer.clientId())
                .clientSecret(keycloakInitializer.clientSecret())
                .authorizationUri("%s/realms/%s/protocol/openid-connect/auth".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .tokenUri("%s/realms/%s/protocol/openid-connect/token".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .userInfoUri("%s/realms/%s/protocol/openid-connect/userinfo".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) // Check if this is correct
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
                .scope(Scopes.names())
                .userNameAttributeName(IdTokenClaimNames.SUB) // Check if this is correct
                .issuerUri("%s/realms/%s".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .jwkSetUri("%s/realms/%s/protocol/openid-connect/certs".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .clientName(keycloakInitializer.clientName())
                .build();
    }

I'm overriding the JwtDecoder with a custom one that accepts a RestTemplate that optionally (based on config) accepts self-signed certs and skips hostname validation:


    @Bean
    public JwtDecoder jwtDecoder(RestTemplate restTemplate) {
        return NimbusJwtDecoder.withIssuerLocation("%s/realms/%s".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .restOperations(restTemplate).build();
    }


    @Bean
    public RestTemplate restTemplate(ClientHttpRequestFactory clientHttpRequestFactory) {
        return new RestTemplate(clientHttpRequestFactory);
    }

    @Bean
    public ClientHttpRequestFactory clientHttpRequestFactory(SslBundles sslBundles, @Value("${keycloak.accept-untrusted-certs}") boolean acceptUntrustedCerts) {
        SSLFactory defaultSslFactory = SSLFactory.builder()
                .withUnsafeTrustMaterial()
                .withUnsafeHostnameVerifier()
                .build();

        CloseableHttpClient httpClient;
        if (acceptUntrustedCerts) {
            LOGGER.info("Accepting untrusted certs for keycloak and ignoring hostname verification.");
            httpClient = HttpClients.custom().setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
                            .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create()
                                    .setSslContext(defaultSslFactory.getSslContext())
                                    .setHostnameVerifier(defaultSslFactory.getHostnameVerifier())
                                    .build())
                            .build())
                    .build();
        } else {
            try {
                SSLContext sslContext = sslBundles.getBundle("keycloak").createSslContext();
                httpClient = HttpClients.custom().setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
                                .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create()
                                        .setSslContext(sslContext)
                                        .build())
                                .build())
                        .build();
                LOGGER.info("Accepting supplied cert for keycloak and applying hostname verification.");
            } catch (NoSuchSslBundleException e) {
                LOGGER.info("Could not find an SSL Context for keycloak. Using default system SSL settings.");
                httpClient = HttpClients.createDefault();
            }
        }

        return new HttpComponentsClientHttpRequestFactory(httpClient);
    }

However, it looks like the default OAuth2UserService instantiates its own RestTemplate, so isn't using mine.

I've tried to override this by providing an OAuth2UserService with my own RestTemplate:

    private OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService(RestTemplate restTemplate) {
        DefaultOAuth2UserService defaultOAuth2UserService = new DefaultOAuth2UserService();
        defaultOAuth2UserService.setRestOperations(restTemplate);
        return defaultOAuth2UserService;
    }

    private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService(OAuth2UserService<OAuth2UserRequest, OAuth2User> userService) {
        OidcUserService oidcUserService = new OidcUserService();
        oidcUserService.setOauth2UserService(userService);
        return oidcUserService;
    }

    @Bean
    public SecurityFilterChain resourceServerFilterChain(HttpSecurity http, RestTemplate restTemplate) throws Exception {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> userService = oauth2UserService(restTemplate);
        http.cors(Customizer.withDefaults())
                .csrf((csrf) -> csrf
                        .csrfTokenRepository(new CookieCsrfTokenRepository())
                        .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
                )
                .authorizeHttpRequests(
                        auth -> auth
                                .requestMatchers(new AntPathRequestMatcher("/api/**"))
                                .authenticated()
                                .anyRequest()
                                .permitAll()
                )
                .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
        http.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
        http.oauth2Client(Customizer.withDefaults());
        http.oauth2Login((oauth2) -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                        .oidcUserService(oidcUserService(userService))
                        .userService(userService)
                ))
                .logout(logout -> logout.addLogoutHandler(keycloakLogoutHandler).logoutSuccessUrl("/"));
        return http.build();
    }

But whenever I authenticate against keycloak, I get the following error:

[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: I/O error on POST request for "https://keycloak:8443/realms/MyRealm/protocol/openid-connect/token": PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

Update

If I override some other endpoint services to use a custom RestTemplate, like so:

    @Bean
    public SecurityFilterChain resourceServerFilterChain(HttpSecurity http, ClientHttpRequestFactory clientHttpRequestFactory) throws Exception {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> userService = oauth2UserService(clientHttpRequestFactory);
        http.cors(Customizer.withDefaults())
                .csrf((csrf) -> csrf
                        .csrfTokenRepository(new CookieCsrfTokenRepository())
                        .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
                )
                .authorizeHttpRequests(
                        auth -> auth
                                .requestMatchers(new AntPathRequestMatcher("/api/**"))
                                .authenticated()
                                .anyRequest()
                                .permitAll()
                )
                .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
        http.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
        http.oauth2Client(Customizer.withDefaults());
        http.oauth2Login((oauth2) -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                        .oidcUserService(oidcUserService(userService))
                        .userService(userService)
                ).tokenEndpoint(token -> token
                                .accessTokenResponseClient(authorizationCodeTokenResponseClient(clientHttpRequestFactory))
                ))
                .logout(logout -> logout.addLogoutHandler(keycloakLogoutHandler).logoutSuccessUrl("/"));
        return http.build();
    }

    private OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService(ClientHttpRequestFactory clientHttpRequestFactory) {
        RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory);
        restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());

        DefaultOAuth2UserService defaultOAuth2UserService = new DefaultOAuth2UserService();
        defaultOAuth2UserService.setRestOperations(restTemplate);
        return defaultOAuth2UserService;
    }

    private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService(OAuth2UserService<OAuth2UserRequest, OAuth2User> userService) {
        OidcUserService oidcUserService = new OidcUserService();
        oidcUserService.setOauth2UserService(userService);
        return oidcUserService;
    }

    private DefaultAuthorizationCodeTokenResponseClient authorizationCodeTokenResponseClient(ClientHttpRequestFactory clientHttpRequestFactory) {
        RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory);
        restTemplate.setMessageConverters(Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter()));
        restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());

        DefaultAuthorizationCodeTokenResponseClient tokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
        tokenResponseClient.setRestOperations(restTemplate);
        return tokenResponseClient;
    }

then I get a different error:

[invalid_id_token] An error occurred while attempting to decode the Jwt: Couldn't retrieve remote JWK set: org.springframework.web.client.ResourceAccessException: I/O error on GET request for "https://keycloak:8443/realms/MyRealm/protocol/openid-connect/certs": PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

This feels like lots of overriding just to tell it to accept my cert.

Question

How do I configure the OAuth2 client to accept self-signed certificates and ignore hostname validation without importing the cert into the trust store?


Solution

  • Don't hack around the hostname that much.

    And that's it. No need to hack the conf around and provide this many @Bean overrides.