SecurityConfig.java
package com.dani.bff.config;
import com.dani.bff.error.ApiError;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* Configures JWT resource-server security and JSON security error responses.
*/
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
/**
* Protects BFF APIs, leaves health checks public, and denies unknown routes.
*
* @param http Spring Security reactive HTTP builder
* @param objectMapper JSON serializer for authentication errors
* @param openApiDocsEnabled whether the OpenAPI JSON/YAML endpoint is intentionally enabled
* @param swaggerUiEnabled whether Springdoc Swagger UI is intentionally enabled
* @return the configured security filter chain
*/
@Bean
public SecurityWebFilterChain securityWebFilterChain(
ServerHttpSecurity http,
ObjectMapper objectMapper,
@Value("${springdoc.api-docs.enabled:false}") boolean openApiDocsEnabled,
@Value("${springdoc.swagger-ui.enabled:false}") boolean swaggerUiEnabled) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(exchanges -> {
exchanges.pathMatchers("/actuator/health", "/actuator/health/**").permitAll();
if (openApiDocsEnabled) {
exchanges.pathMatchers("/v3/api-docs", "/v3/api-docs.yaml", "/v3/api-docs/**").permitAll();
}
if (swaggerUiEnabled) {
exchanges.pathMatchers("/swagger-ui.html", "/swagger-ui/**").permitAll();
}
exchanges.pathMatchers("/api/**").authenticated()
.anyExchange().denyAll();
})
.exceptionHandling(errors -> errors
.authenticationEntryPoint(authenticationEntryPoint(objectMapper))
.accessDeniedHandler(accessDeniedHandler(objectMapper)))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.build();
}
/**
* Builds a JWT decoder from JWK, issuer discovery, or local HMAC settings in that order.
*
* @param properties externalized JWT settings
* @return a reactive JWT decoder used by the resource server
*/
@Bean
public ReactiveJwtDecoder reactiveJwtDecoder(JwtSecurityProperties properties) {
NimbusReactiveJwtDecoder decoder;
if (StringUtils.hasText(properties.getJwkSetUri())) {
decoder = NimbusReactiveJwtDecoder.withJwkSetUri(properties.getJwkSetUri()).build();
} else if (StringUtils.hasText(properties.getIssuerUri())) {
decoder = NimbusReactiveJwtDecoder.withIssuerLocation(properties.getIssuerUri()).build();
} else if (StringUtils.hasText(properties.getSecret())) {
decoder = NimbusReactiveJwtDecoder.withSecretKey(secretKey(properties.getSecret()))
.macAlgorithm(MacAlgorithm.HS256)
.build();
} else {
throw new IllegalStateException("Configure security.jwt.jwk-set-uri, security.jwt.issuer-uri, or security.jwt.secret");
}
decoder.setJwtValidator(jwtValidator(properties));
return decoder;
}
private static SecretKey secretKey(String secret) {
byte[] secretBytes = secret.getBytes(StandardCharsets.UTF_8);
if (secretBytes.length < 32) {
throw new IllegalStateException("security.jwt.secret must contain at least 32 bytes for HS256");
}
return new SecretKeySpec(secretBytes, "HmacSHA256");
}
private static OAuth2TokenValidator<Jwt> jwtValidator(JwtSecurityProperties properties) {
String expectedIssuer = StringUtils.hasText(properties.getIssuer())
? properties.getIssuer()
: properties.getIssuerUri();
OAuth2TokenValidator<Jwt> defaultValidator = StringUtils.hasText(expectedIssuer)
? JwtValidators.createDefaultWithIssuer(expectedIssuer)
: JwtValidators.createDefault();
if (!StringUtils.hasText(properties.getAudience())) {
return defaultValidator;
}
OAuth2TokenValidator<Jwt> audienceValidator = jwt -> jwt.getAudience().contains(properties.getAudience())
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(new OAuth2Error(
"invalid_token",
"JWT is missing required audience " + properties.getAudience(),
null));
return new DelegatingOAuth2TokenValidator<>(defaultValidator, audienceValidator);
}
private static ServerAuthenticationEntryPoint authenticationEntryPoint(ObjectMapper objectMapper) {
return (exchange, ex) -> writeJsonError(exchange, objectMapper, HttpStatus.UNAUTHORIZED, "Authentication is required");
}
private static ServerAccessDeniedHandler accessDeniedHandler(ObjectMapper objectMapper) {
return (exchange, ex) -> writeJsonError(exchange, objectMapper, HttpStatus.FORBIDDEN, "Access is denied");
}
private static Mono<Void> writeJsonError(
ServerWebExchange exchange,
ObjectMapper objectMapper,
HttpStatus status,
String message) {
var response = exchange.getResponse();
if (response.isCommitted()) {
return Mono.empty();
}
response.setStatusCode(status);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
ApiError error = ApiError.of(status, message, exchange.getRequest().getPath().pathWithinApplication().value());
try {
byte[] bytes = objectMapper.writeValueAsBytes(error);
return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes)));
} catch (JsonProcessingException ex) {
return response.setComplete();
}
}
}