Resource Server con Spring Security¶
Un Resource Server es un servicio que protege sus endpoints verificando que cada request lleve un JWT válido emitido por Keycloak. Spring Boot 4 + Spring Security 7 + Spring WebFlux configura esto de forma declarativa con muy poco código.
Dependencias¶
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
Configuración mínima¶
# src/main/resources/application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
# Keycloak descarga automáticamente el JWKS y valida issuer
issuer-uri: https://auth.example.com/realms/realm-internal
# Alternativa explícita (más rápido al arrancar):
# jwk-set-uri: https://auth.example.com/realms/realm-internal/protocol/openid-connect/certs
server:
port: 8081
management:
endpoints:
web:
exposure:
include: health,info,prometheus
SecurityConfig completo¶
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity(useAuthorizationManager = true)
public class SecurityConfig {
@Bean
public SecurityWebFilterChain securityFilterChain(
ServerHttpSecurity http,
ReactiveJwtDecoder jwtDecoder) {
return http
// Deshabilitar CSRF (stateless JWT, no sesiones de formulario)
.csrf(ServerHttpSecurity.CsrfSpec::disable)
// Deshabilitar login por formulario
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
// Reglas de autorización por ruta
.authorizeExchange(auth -> auth
// Actuator health/info públicos (para health checks de Docker)
.pathMatchers("/actuator/health", "/actuator/info").permitAll()
// Cualquier otra ruta requiere autenticación
.anyExchange().authenticated()
)
// Configurar como Resource Server JWT
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtDecoder(jwtDecoder)
.jwtAuthenticationConverter(keycloakRolesConverter())
)
)
.build();
}
@Bean
public ReactiveJwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuerUri) {
return ReactiveJwtDecoders.fromIssuerLocation(issuerUri);
}
/**
* Extrae los roles de Keycloak desde realm_access.roles
* y los expone como GrantedAuthority con prefijo ROLE_
*/
@Bean
public Converter<Jwt, Mono<AbstractAuthenticationToken>> keycloakRolesConverter() {
return jwt -> {
Map<String, Object> realmAccess =
jwt.getClaimAsMap("realm_access");
List<GrantedAuthority> authorities = Optional.ofNullable(realmAccess)
.map(ra -> (List<String>) ra.get("roles"))
.orElse(List.of())
.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
// También incluir scopes del token como authorities
String scope = jwt.getClaimAsString("scope");
if (scope != null) {
Arrays.stream(scope.split(" "))
.map(s -> new SimpleGrantedAuthority("SCOPE_" + s))
.forEach(authorities::add);
}
return Mono.just(new JwtAuthenticationToken(jwt, authorities, jwt.getSubject()));
};
}
}
Endpoints protegidos — ejemplo¶
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class RecursoController {
private final RecursoService recursoService;
// Cualquier usuario autenticado puede listar
@GetMapping("/recursos")
public Flux<RecursoResponse> listar(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
return recursoService.findByUser(userId);
}
// Solo usuarios con rol ADMIN pueden crear
@PostMapping("/recursos")
@PreAuthorize("hasRole('ADMIN') or hasAuthority('SCOPE_recursos:write')")
public Mono<RecursoResponse> crear(
@RequestBody @Valid RecursoRequest request,
@AuthenticationPrincipal Jwt jwt) {
return recursoService.create(request, jwt.getSubject());
}
// Solo ADMIN puede eliminar
@DeleteMapping("/recursos/{id}")
@PreAuthorize("hasRole('ADMIN')")
public Mono<Void> eliminar(@PathVariable String id) {
return recursoService.delete(id);
}
}
Claims disponibles en el JWT de Keycloak¶
@GetMapping("/me")
public Mono<Map<String, Object>> perfil(@AuthenticationPrincipal Jwt jwt) {
return Mono.just(Map.of(
"sub", jwt.getSubject(),
"username", jwt.getClaimAsString("preferred_username"),
"email", jwt.getClaimAsString("email"),
"name", jwt.getClaimAsString("name"),
"realm", jwt.getClaimAsString("iss"),
"roles", ((Map<?, ?>) jwt.getClaims().getOrDefault("realm_access", Map.of()))
.getOrDefault("roles", List.of())
));
}
Verificar con curl¶
KC_URL="https://auth.example.com"
REALM="realm-internal"
# Obtener token (solo para pruebas con direct access grants habilitado)
TOKEN=$(curl -s -X POST \
"${KC_URL}/realms/${REALM}/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=partner-portal-app" \
-d "username=testuser" \
-d "password=testpass" \
-d "scope=openid" | jq -r '.access_token')
# Llamar al endpoint protegido
curl -s -H "Authorization: Bearer $TOKEN" \
http://localhost:8081/api/v1/recursos | jq .
# Sin token → 401
curl -s http://localhost:8081/api/v1/recursos
# {"error":"unauthorized","message":"Full authentication is required"}
# Health check (público)
curl -s http://localhost:8081/actuator/health | jq .