springspring-securityspring-authorization-server

Spring - Exposing public endpoints when using JWT authorization server with WebFlux


I want to create a BFF micro-service, which would be responsible for (among other things) forwarding username/password credentials to Keycloak (where Keycloak issues a JWT token) and for validating said tokens with Keycloak on protected endpoints.

I'm having some issues exposing public endpoints, however. I want to expose an endpoint, which does not need JWT security (since it's the endpoint where the user will POST their credentials to exchange for a token), but can't get it to work. I want to expose the endpoint api/v1/backoffice/auth

Can someone guide me how this is done?

Security config:

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain globalSecurityWebFilterChain(ServerHttpSecurity http) {
        return http
                .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
                .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .logout(ServerHttpSecurity.LogoutSpec::disable)
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
                .authorizeExchange(exchanges -> exchanges.anyExchange().authenticated())
                .oauth2ResourceServer(oAuth -> oAuth.jwt(Customizer.withDefaults()))
                .build();
    }
}

Dependencies:

dependencies {
    // Intra-project dependencies
    implementation(project(":common"))

    // Security dependencies
    implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.1")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.4.1")
    implementation("org.springframework.boot:spring-boot-starter-security:3.4.1")
    implementation("org.springframework.boot:spring-boot-starter-webflux:3.4.1")

    // Dev dependencies
    developmentOnly("org.springframework.boot:spring-boot-devtools:3.4.1")

    // Communication
    implementation("org.springframework.boot:spring-boot-starter-amqp:3.4.1")
    implementation("org.springframework.amqp:spring-rabbit-stream:3.2.1")

    // Cloud capacity
    implementation("org.springframework.cloud:spring-cloud-starter-config:4.2.0")
    implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client:4.2.0") {
        exclude(group = "com.fasterxml.woodstox", module = "woodstox-core")
        exclude(group = "org.apache.httpcomponents", module = "httpclient")
        exclude(group = "com.thoughtworks.xstream", module = "xstream")
    }

    // resolve transitive dependency security issues
    implementation("com.fasterxml.woodstox:woodstox-core:7.1.0")
    implementation("org.apache.httpcomponents:httpclient:4.5.14")
    implementation("com.thoughtworks.xstream:xstream:1.4.21")
}

Controller to handle the call (haven't tested it at all)

@RestController
@RequestMapping("/api/v1/backoffice")
public class BoAuthController {

    @Value("${keycloak.client.secret}")
    private String clientSecret;

    private final WebClient webClient = WebClient.create("http://localhost:8080");

    @PostMapping("/auth")
    public Mono<ResponseEntity<Map>> login(@RequestBody Map<String, String> credentials) {
        return webClient.post()
                .uri("/realms/backoffice-realm/protocol/openid-connect/token")
                .header("Content-Type", "application/x-www-form-urlencoded")
                .bodyValue("grant_type=password&client_id=backoffice-client&client_secret=" + clientSecret
                        + "&username=" + credentials.get("username")
                        + "&password=" + credentials.get("password"))
                .retrieve()
                .bodyToMono(Map.class)
                .map(ResponseEntity::ok)
                .onErrorResume(error -> Mono.just(
                        ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                                .body(Map.of("error", "Invalid credentials"))
                ));
    }
}

Solution

  • @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
                                                         CustomUserDetailsService customUserDetailsService,
                                                         JwtAuthFilter jwtAuthFilter) {
        return http.csrf(ServerHttpSecurity.CsrfSpec::disable)
                .authorizeExchange(exchanges -> exchanges
                        .pathMatchers(
                                "/swagger-ui/**",
                                "/v3/api-docs/**",
                                "/api/v1/auth/**"
                        )
                        .permitAll()
                        .anyExchange()
                        .authenticated()
                )
                .securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) // Stateless session
                .headers(headers -> headers
                        .frameOptions(ServerHttpSecurity.HeaderSpec.FrameOptionsSpec::disable)) // Allow H2 console
                .exceptionHandling(exceptionHandling -> exceptionHandling
                        .authenticationEntryPoint((exchange, e) ->
                                Mono.fromRunnable(() -> exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED))))
                .addFilterAt(jwtAuthFilter, SecurityWebFiltersOrder.AUTHENTICATION)
                .build();
    }