Spring WebFlux Security¶
Spring Security 7 + WebFlux configura la seguridad de forma completamente reactiva: no hay FilterChain basado en Servlet, sino un SecurityWebFilterChain construido sobre el pipeline de Project Reactor. Cada componente es non-blocking y se integra con el contexto reactivo de Reactor a través de ReactiveSecurityContextHolder. Esta página documenta la configuración completa para un Resource Server que valida JWTs emitidos por Keycloak 26.x.
Dependencias Maven¶
<!-- pom.xml — Spring Boot 4.x -->
<dependencies>
<!-- Starter WebFlux: Netty + Spring WebFlux -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Spring Security para WebFlux + OAuth2 Resource Server -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- Soporte JWT: nimbus-jose-jwt viene transitivamente -->
<!-- No necesitas añadir oauth2-jose explícitamente en Boot 4 -->
<!-- Actuator para health/metrics -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
Configuración application.yml¶
spring:
security:
oauth2:
resourceserver:
jwt:
# Keycloak expone el JWKS en este endpoint
# Spring lo usa para cachear las claves públicas y validar firmas
jwk-set-uri: https://auth.empresa.com/realms/realm-partners/protocol/openid-connect/certs
# issuer-uri activa la validación del claim "iss" en cada JWT
issuer-uri: https://auth.empresa.com/realms/realm-partners
server:
port: 8080
# Logging de seguridad — útil en desarrollo, desactivar en producción
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.oauth2: TRACE
jwk-set-uri vs issuer-uri
Si configuras solo issuer-uri, Spring realiza una llamada al endpoint de discovery OIDC (/.well-known/openid-configuration) en el arranque para obtener el JWKS URI. Si configuras ambos, jwk-set-uri tiene precedencia para la validación de firma y issuer-uri se usa para validar el claim iss. En entornos donde Keycloak no es accesible en el arranque (network policies estrictas), especifica jwk-set-uri directamente.
SecurityConfig.java — Configuración completa¶
package com.empresa.platform.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
import reactor.core.publisher.Mono;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity // Habilita @PreAuthorize en métodos reactivos
public class SecurityConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwkSetUri;
/**
* SecurityWebFilterChain — cadena de filtros reactiva.
*
* Orden de filtros internos (gestionado por Spring Security):
* 1. HttpsRedirectWebFilter (si se activa)
* 2. CorsWebFilter
* 3. CsrfWebFilter (desactivado para APIs stateless)
* 4. SecurityContextServerWebExchangeWebFilter
* 5. ServerRequestCacheWebFilter
* 6. SecurityWebFiltersOrder.AUTHENTICATION → BearerTokenAuthenticationWebFilter
* 7. ExceptionTranslationWebFilter
* 8. AuthorizationWebFilter
*/
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
// CSRF: desactivado — API stateless con JWT; no hay cookies de sesión
.csrf(ServerHttpSecurity.CsrfSpec::disable)
// CORS: configurado vía CorsConfigurationSource bean separado
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// Reglas de autorización
.authorizeExchange(exchanges -> exchanges
// Endpoints públicos — no requieren token
.matchers(ServerWebExchangeMatchers.pathMatchers(
"/actuator/health",
"/actuator/health/**",
"/actuator/info",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html"
)).permitAll()
// Endpoints de administración — solo rol ADMIN
.matchers(ServerWebExchangeMatchers.pathMatchers("/admin/**"))
.hasRole("ADMIN")
// Endpoints de API — autenticado con cualquier rol válido
.matchers(ServerWebExchangeMatchers.pathMatchers("/api/**"))
.authenticated()
// Todo lo demás — autenticado
.anyExchange().authenticated()
)
// Resource Server con JWT
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtDecoder(reactiveJwtDecoder())
.jwtAuthenticationConverter(keycloakJwtAuthenticationConverter())
)
)
// Sin sesión HTTP — la autenticación es stateless vía JWT
.requestCache(cache -> cache.disable())
.build();
}
/**
* ReactiveJwtDecoder configura NimbusReactiveJwtDecoder con el JWKS de Keycloak.
*
* NimbusReactiveJwtDecoder:
* - Descarga las claves públicas del JWKS URI de Keycloak en el primer uso.
* - Cachea las claves en memoria (TTL configurable).
* - Rota automáticamente cuando Keycloak rota sus signing keys (kid mismatch).
* - Valida: firma RSA/EC, exp, nbf, iss (si se configura JwtValidators).
*/
@Bean
public ReactiveJwtDecoder reactiveJwtDecoder() {
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
.withJwkSetUri(jwkSetUri)
.build();
// Validadores adicionales — el default ya incluye exp y nbf
// Para añadir validación de audience:
// OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<>(
// JwtClaimNames.AUD,
// aud -> aud != null && aud.contains("partner-portal-app")
// );
// OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(
// JwtValidators.createDefaultWithIssuer(issuerUri),
// audienceValidator
// );
// decoder.setJwtValidator(withAudience);
return decoder;
}
/**
* Converter que extrae los roles de Keycloak del JWT y los convierte en
* GrantedAuthority de Spring Security.
*
* Estructura del JWT de Keycloak (claims relevantes):
* {
* "realm_access": { "roles": ["ROLE_USER", "ROLE_ADMIN"] },
* "resource_access": {
* "partner-portal-app": { "roles": ["ROLE_PARTNER_READ"] }
* },
* "scope": "openid profile email"
* }
*/
@Bean
public Converter<Jwt, Mono<AbstractAuthenticationToken>> keycloakJwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(keycloakGrantedAuthoritiesConverter());
// El "principal name" del Authentication será el sub del JWT (UUID del usuario en Keycloak)
converter.setPrincipalClaimName("preferred_username");
return new ReactiveJwtAuthenticationConverterAdapter(converter);
}
/**
* Extrae GrantedAuthority desde realm_access.roles y resource_access.<clientId>.roles.
*/
private Converter<Jwt, Collection<GrantedAuthority>> keycloakGrantedAuthoritiesConverter() {
return jwt -> {
// 1. Roles de realm
Collection<GrantedAuthority> realmRoles = extractRealmRoles(jwt);
// 2. Roles del cliente específico (resource_access)
Collection<GrantedAuthority> clientRoles = extractClientRoles(jwt, "partner-portal-app");
return Stream.concat(realmRoles.stream(), clientRoles.stream())
.collect(Collectors.toList());
};
}
@SuppressWarnings("unchecked")
private Collection<GrantedAuthority> extractRealmRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null || !realmAccess.containsKey("roles")) {
return Collections.emptyList();
}
List<String> roles = (List<String>) realmAccess.get("roles");
return roles.stream()
// Prefijo ROLE_ requerido por Spring Security para hasRole()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
}
@SuppressWarnings("unchecked")
private Collection<GrantedAuthority> extractClientRoles(Jwt jwt, String clientId) {
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
if (resourceAccess == null || !resourceAccess.containsKey(clientId)) {
return Collections.emptyList();
}
Map<String, Object> clientAccess = (Map<String, Object>) resourceAccess.get(clientId);
List<String> roles = (List<String>) clientAccess.getOrDefault("roles", List.of());
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
}
@Bean
public org.springframework.web.cors.reactive.CorsConfigurationSource corsConfigurationSource() {
var config = new org.springframework.web.cors.CorsConfiguration();
config.setAllowedOriginPatterns(List.of(
"https://partners.empresa.com",
"https://app.empresa.com"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-ID"));
config.setExposedHeaders(List.of("X-Request-ID", "X-Correlation-ID"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
var source = new org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
Orden de filtros reactivos¶
Spring Security WebFlux gestiona los filtros en un orden fijo definido por SecurityWebFiltersOrder. El pipeline se aplica a cada ServerWebExchange (equivalente reactivo de HttpServletRequest + HttpServletResponse):
Incoming request (Netty)
│
▼
┌─────────────────────────────────────────────┐
│ 1. HttpsRedirectWebFilter │ (solo si httpsRedirectEnabled)
│ 2. CorsWebFilter │ (preflight OPTIONS handled here)
│ 3. CsrfWebFilter │ (disabled para APIs JWT)
│ 4. SecurityContextServerWebExchangeWebFilter│ (inyecta SecurityContext en exchange)
│ 5. ServerRequestCacheWebFilter │ (disabled para APIs stateless)
│ 6. LogoutWebFilter │ (maneja POST /logout)
│ 7. BearerTokenAuthenticationWebFilter │ (extrae "Authorization: Bearer ...")
│ └─ Invoca ReactiveJwtDecoder │ (verifica firma y claims)
│ └─ Invoca JwtAuthenticationConverter │ (extrae roles → GrantedAuthority)
│ └─ Guarda en ReactiveSecurityContextHolder
│ 8. ExceptionTranslationWebFilter │ (transforma 401/403)
│ 9. AuthorizationWebFilter │ (evalúa reglas authorizeExchange)
└─────────────────────────────────────────────┘
│
▼
Handler (Controller / Router Function)
Contexto de seguridad reactivo¶
En WebFlux, el SecurityContext se propaga a través del Context de Reactor (no con ThreadLocal). Esto es crítico porque las operaciones asíncronas saltan de un hilo a otro.
Lectura del contexto en un handler¶
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
public class ProfileController {
@GetMapping("/api/me")
public Mono<UserProfileResponse> getProfile() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.cast(JwtAuthenticationToken.class)
.map(auth -> {
Jwt jwt = auth.getToken();
return UserProfileResponse.builder()
.username(jwt.getClaimAsString("preferred_username"))
.email(jwt.getClaimAsString("email"))
.roles(auth.getAuthorities().stream()
.map(ga -> ga.getAuthority())
.toList())
.build();
});
}
}
No uses SecurityContextHolder en WebFlux
SecurityContextHolder.getContext() es ThreadLocal y no funciona en entornos reactivos. Siempre usa ReactiveSecurityContextHolder.getContext(). El contexto se propaga automáticamente en la cadena de operadores Reactor gracias al Context de Reactor vinculado por BearerTokenAuthenticationWebFilter.
Propagación del contexto en operaciones flatMap¶
// CORRECTO: el contexto se propaga automáticamente en la cadena Reactor
public Mono<OrderResponse> createOrder(OrderRequest request) {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> extractUserId(ctx.getAuthentication()))
.flatMap(userId -> orderService.create(request, userId))
.flatMap(order -> notificationService.notify(order)); // contexto disponible aquí
}
// INCORRECTO: publishOn/subscribeOn en schedulers externos pueden perder el contexto
// Si necesitas cambiar de scheduler, usa Hooks.onLastOperator con Micrometer
Integración con Keycloak JWKS¶
Keycloak expone su JWKS en:
Respuesta típica:
{
"keys": [
{
"kid": "abc123",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "...",
"e": "AQAB",
"x5c": ["..."]
}
]
}
NimbusReactiveJwtDecoder descarga estas claves en el primer JWT recibido y las cachea. Cuando Keycloak rota sus claves (por defecto cada 6 horas), la primera petición con el nuevo kid produce un cache miss, lo que dispara una nueva descarga del JWKS. No es necesario reiniciar el Resource Server cuando Keycloak rota claves.
sequenceDiagram
participant RS as Resource Server
participant Cache as JWKS Cache (en memoria)
participant KC as Keycloak JWKS endpoint
RS->>RS: Recibe JWT con kid="abc123"
RS->>Cache: ¿Tengo la clave kid="abc123"?
alt Cache hit
Cache-->>RS: RSAPublicKey
else Cache miss
RS->>KC: GET /certs
KC-->>RS: { keys: [{kid:"abc123", ...}] }
RS->>Cache: Guarda kid="abc123" → RSAPublicKey
Cache-->>RS: RSAPublicKey
end
RS->>RS: Verifica firma JWT con RSAPublicKey
RS->>RS: Valida exp, iss, nbf
RS->>RS: Invoca JwtAuthenticationConverter
Manejo de errores de autenticación¶
Spring Security WebFlux maneja automáticamente los errores, pero puedes personalizar las respuestas:
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
// ... configuración anterior ...
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtDecoder(reactiveJwtDecoder()))
.authenticationEntryPoint((exchange, ex) -> {
// 401 con WWW-Authenticate header y detalle del error
exchange.getResponse().setStatusCode(
org.springframework.http.HttpStatus.UNAUTHORIZED);
exchange.getResponse().getHeaders().set(
"WWW-Authenticate",
"Bearer realm=\"partner-portal\", error=\"invalid_token\", " +
"error_description=\"" + ex.getMessage() + "\""
);
exchange.getResponse().getHeaders().setContentType(
org.springframework.http.MediaType.APPLICATION_JSON);
var body = """
{"error":"unauthorized","message":"%s"}
""".formatted(ex.getMessage());
var buffer = exchange.getResponse().bufferFactory()
.wrap(body.getBytes());
return exchange.getResponse().writeWith(Mono.just(buffer));
})
)
.build();
}
Actuator y seguridad¶
Los endpoints de Actuator deben estar separados del puerto de la API en producción (puerto de management), o protegidos explícitamente:
# application.yml
management:
server:
port: 8081 # Puerto separado para actuator — no expuesto públicamente
endpoints:
web:
exposure:
include: health, info, prometheus, metrics
endpoint:
health:
show-details: always
probes:
enabled: true # /health/liveness y /health/readiness para Kubernetes
Con puerto de management separado, el SecurityWebFilterChain en el puerto 8080 no aplica a las rutas de Actuator en 8081, simplificando la configuración de seguridad.