javaspringspring-bootspring-securityspring-webflux

Handling Internal Server Error When OAuth Issuer Is Not Available in WebFluxSecurity


I cannot customize the default "500 internal server error" response body in case the issuer (Keycloak) for my reactive Spring resource server is unavailable. I want to add a custom JSON response, but, for example, the usual @ExceptionHandler does not work because the authentication won't pass.

pom.xml (Spring security v6.2.0, Spring boot v3.2.0):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-jose</artifactId>
</dependency>

application.yaml (The issuer-uri is wrong on purpose: I want to simulate that my authentication server, Keycloak, is not available):

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${JWT_ISSUER_URI:http://notavailable.io/realms/my-realm}

SecurityConfig.java:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;

import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@EnableWebFluxSecurity
public class SecurityConfiguration {


    @Bean
    SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        return http.authorizeExchange(exchanges -> exchanges.pathMatchers("/actuator/**")
                        .permitAll()
                        .anyExchange()
                        .hasAuthority("SCOPE_foobar"))
                .oauth2ResourceServer(oAuth2ResourceServerSpec -> oAuth2ResourceServerSpec.jwt(withDefaults())
                        .authenticationEntryPoint((webExchance, exception) -> handleAuthError(webExchance))
                        .accessDeniedHandler((webExchance, exception) -> handleAuthError(webExchance)))
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .build();
    }

    /**
     * @SO: This handle is called if the authentication fails with 401 or 403. But it's not called if an 500 internal
     *      server error is thrown.
     */
    private Mono<Void> handleAuthError(ServerWebExchange webExchance) {
        final ServerHttpResponse response = webExchance.getResponse();
        response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
        response.getHeaders().setContentType(APPLICATION_JSON);

        final var msg = "{\"foo\":\"bar\"}".getBytes(StandardCharsets.UTF_8);
        return response.writeWith(Mono.just(response.bufferFactory().wrap(msg)));
    }
}

If I send a request to my server (there is a /foobar controller) while the issuer-uri is not available, the server logs the following stacktrace:

Error has been observed at the following site(s):
    *__checkpoint ⇢ AuthenticationWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ ReactorContextWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ HttpHeaderWriterWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ ServerWebExchangeReactorContextWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.WebFilterChainProxy [DefaultWebFilterChain]
    *__checkpoint ⇢ HTTP GET "/foobar" [ExceptionHandlingWebHandler]
Original Stack Trace:
        at org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager.onError(JwtReactiveAuthenticationManager.java:81)
        at reactor.core.publisher.Mono.lambda$onErrorMap$27(Mono.java:3785)
        at reactor.core.publisher.Mono.lambda$onErrorResume$29(Mono.java:3875)
        at reactor.core.publisher.FluxOnErrorResume$ResumeSubscriber.onError(FluxOnErrorResume.java:94)
[...]
Caused by: org.springframework.security.oauth2.jwt.JwtException: An error occurred while attempting to decode the Jwt: 
    at org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder.lambda$decode$2(NimbusReactiveJwtDecoder.java:171)
    at reactor.core.publisher.Mono.lambda$onErrorMap$27(Mono.java:3785)
    at reactor.core.publisher.Mono.lambda$onErrorResume$29(Mono.java:3875)
    at reactor.core.publisher.FluxOnErrorResume$ResumeSubscriber.onError(FluxOnErrorResume.java:94)
[...]
Caused by: java.lang.IllegalArgumentException: Unable to resolve the Configuration with the provided Issuer of "http://notavailable.io/realms/foobar/realms/my-realm"
    at org.springframework.security.oauth2.jwt.ReactiveJwtDecoderProviderConfigurationUtils.lambda$getConfiguration$8(ReactiveJwtDecoderProviderConfigurationUtils.java:139)
    at reactor.core.publisher.Flux.lambda$onErrorMap$28(Flux.java:7239)
    at reactor.core.publisher.Flux.lambda$onErrorResume$29(Flux.java:7292)
[...]
Caused by: org.springframework.web.reactive.function.client.WebClientRequestException: Failed to resolve 'notavailable.io' [A(1)] after 4 queries 
    at org.springframework.web.reactive.function.client.ExchangeFunctions$DefaultExchangeFunction.lambda$wrapException$9(ExchangeFunctions.java:136)

The IllegalArgumentException is thrown in the org.springframework.security.oauth2.jwt.ReactiveJwtDecoderProviderConfigurationUtils#getConfiguration (in the onErrorMap line) whose impl looks like this:

    private static Mono<Map<String, Object>> getConfiguration(String issuer, WebClient web, URI... uris) {
        String errorMessage = "Unable to resolve the Configuration with the provided Issuer of " + "\"" + issuer + "\"";
        return Flux.just(uris)
            .concatMap((uri) -> web.get().uri(uri).retrieve().bodyToMono(STRING_OBJECT_MAP))
            .flatMap((configuration) -> {
                if (configuration.get("jwks_uri") == null) {
                    return Mono.error(() -> new IllegalArgumentException("The public JWK set URI must not be null"));
                }
                return Mono.just(configuration);
            })
            .onErrorContinue((ex) -> ex instanceof WebClientResponseException
                    && ((WebClientResponseException) ex).getStatusCode().is4xxClientError(), (ex, object) -> {
                    })
            .onErrorMap(RuntimeException.class,
                    (ex) -> (ex instanceof IllegalArgumentException) ? ex
                            : new IllegalArgumentException(errorMessage, ex))
            .next()
            .switchIfEmpty(Mono.error(() -> new IllegalArgumentException(errorMessage)));
    }

Actual Behavior

If the issuer is not available, the client gets a 500 internal server error response with the body:

{
    "timestamp": "2024-06-21T16:18:13.190+00:00",
    "path": "/foobar",
    "status": 500,
    "error": "Internal Server Error",
    "requestId": "2eeaa482-2"
}

Desired Behavior

If the issuer is not available, the client gets a 500 internal server error response with the body:

{
    "foo": "bar"
}

I tried using @ExceptionHandler (is not called) and creating my own JwtDecoder (I don't find a method I could override to catch the exception and send a response).

Does anyone know how to modify the response when the issuer-URI is unavailable?


Solution

  • The method ServerHttpSecurity.OAuth2ResourceServerSpec#authenticationFailureHandler(ServerAuthenticationFailureHandler) was the one I was looking for. This is called before the authentication fails and when the JWT decoder cannot reach the issuer to download the public key for JWT signing verficiation.

    Updated code:

    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.server.reactive.ServerHttpResponse;
    import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
    import org.springframework.security.config.web.server.ServerHttpSecurity;
    import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
    import org.springframework.security.web.server.SecurityWebFilterChain;
    import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler;
    import org.springframework.web.server.ServerWebExchange;
    import reactor.core.publisher.Mono;
    
    import java.nio.charset.StandardCharsets;
    
    import static org.springframework.http.MediaType.APPLICATION_JSON;
    import static org.springframework.security.config.Customizer.withDefaults;
    
    @Configuration
    @EnableWebFluxSecurity
    public class SecurityConfiguration {
    
    
        @Bean
        SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
            return http.authorizeExchange(exchanges -> exchanges.pathMatchers("/actuator/**")
                            .permitAll()
                            .anyExchange()
                            .hasAuthority("SCOPE_foobar"))
                    .oauth2ResourceServer(oAuth2ResourceServerSpec -> oAuth2ResourceServerSpec.jwt(withDefaults())
                            .authenticationEntryPoint((webExchance, exception) -> handleAuthError(webExchance))
                            .accessDeniedHandler((webExchance, exception) -> handleAuthError(webExchance))
                            .authenticationFailureHandler(createFailureHandler()))
                    .csrf(ServerHttpSecurity.CsrfSpec::disable)
                    .build();
        }
    
        private ServerAuthenticationFailureHandler createFailureHandler() {
            return (webFilterExchange, exception) -> handleAuthError(webFilterExchange.getExchange());
        }
    
        private Mono<Void> handleAuthError(ServerWebExchange webExchance) {
            final ServerHttpResponse response = webExchance.getResponse();
            response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
            response.getHeaders().setContentType(APPLICATION_JSON);
    
            final var msg = "{\"foo\":\"bar\"}".getBytes(StandardCharsets.UTF_8);
            return response.writeWith(Mono.just(response.bufferFactory().wrap(msg)));
        }
    }