Search code examples
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.

    • Generate a certificate with the right CN (hostname) and add it to your JREs / JDKs cacert files. I propose a script for that.
    • Configure Keycloak to use this certificate. Also set the hostname property. In a docker compose file, this is done by defining the KC_HOSTNAME "environment" (and maybe KC_HOSTNAME_STRICT_BACKCHANNEL: true too). This will instruct Keycloak what to use as host in the URIs it puts in the OpenID configuration.
    • Use your hostname in Spring configuration (in the isser-uri, let the auto-configuration from OpenID conf set the URIs for JWK, authorization, token, userinfo, etc.).
    • Add this certificate to the trusted stores of your OS. The README.md of the repo above contains instructions for Windows and OS X. This will remove the warnings from your browser when the certificate is used for a page you visit (Keycloak admin UI for instance).

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