Guía — Spring Boot 4 Resource Server¶
Un Resource Server en OAuth 2.0 es el servicio que expone los datos protegidos y valida que el Bearer Token presentado por el cliente sea válido antes de procesar la petición. Esta guía documenta el proceso completo para configurar un microservicio Spring Boot 4 + WebFlux como Resource Server integrado con Keycloak 26.x, desde las dependencias hasta el primer endpoint protegido probado con curl.
Prerequisitos¶
- Java 21 (recomendado: GraalVM 21 o Eclipse Temurin 21)
- Spring Boot 4.x
- Keycloak 26.x en ejecución con un realm configurado (ver
realm-partnersorealm-internal) - Maven 3.9+ o Gradle 8.x
Paso 1 — Dependencias¶
Maven¶
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.0</version>
<relativePath/>
</parent>
<groupId>com.empresa</groupId>
<artifactId>partner-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- WebFlux: servidor Netty + programación reactiva -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- OAuth2 Resource Server: incluye spring-security-oauth2-resource-server
y nimbus-jose-jwt para validación de JWTs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- Actuator: health checks, métricas Prometheus -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Micrometer Prometheus registry -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Gradle (Kotlin DSL)¶
// build.gradle.kts
plugins {
id("org.springframework.boot") version "4.0.0"
id("io.spring.dependency-management") version "1.1.7"
kotlin("jvm") version "2.1.0"
kotlin("plugin.spring") version "2.1.0"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("io.projectreactor:reactor-test")
}
Paso 2 — application.yml¶
spring:
application:
name: partner-api
security:
oauth2:
resourceserver:
jwt:
# URI del JWKS de Keycloak para este realm
# Spring descarga y cachea las claves públicas RSA automáticamente
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 recibido
issuer-uri: https://auth.empresa.com/realms/realm-partners
server:
port: 8080
management:
server:
port: 8081
endpoints:
web:
exposure:
include: health, info, prometheus, metrics
endpoint:
health:
show-details: always
probes:
enabled: true
# Para desarrollo local: apunta a Keycloak en Docker Compose
---
spring:
config:
activate:
on-profile: local
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:8180/realms/realm-partners/protocol/openid-connect/certs
issuer-uri: http://localhost:8180/realms/realm-partners
Perfil local para desarrollo
Con spring.config.activate.on-profile: local, el archivo application.yml define overrides para el entorno de desarrollo. Activa el perfil con -Dspring.profiles.active=local o SPRING_PROFILES_ACTIVE=local.
Paso 3 — SecurityConfig.java completo¶
package com.empresa.partnerapi.config;
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.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
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.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
public class SecurityConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwkSetUri;
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
/**
* Cliente de Keycloak cuya audiencia debe aparecer en el token.
* Ajusta este valor al client_id de tu aplicación en Keycloak.
*/
private static final String EXPECTED_AUDIENCE = "partner-portal-app";
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(exchanges -> exchanges
// Actuator en puerto separado no pasa por este filter chain
// Rutas públicas del API
.matchers(ServerWebExchangeMatchers.pathMatchers(
"/v3/api-docs/**",
"/swagger-ui/**"
)).permitAll()
// Rutas protegidas
.matchers(ServerWebExchangeMatchers.pathMatchers("/api/partners/**"))
.hasAnyRole("PARTNER_READ", "PARTNER_ADMIN", "ADMIN")
.matchers(ServerWebExchangeMatchers.pathMatchers("/api/admin/**"))
.hasRole("ADMIN")
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtDecoder(reactiveJwtDecoder())
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.build();
}
@Bean
public ReactiveJwtDecoder reactiveJwtDecoder() {
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
.withJwkSetUri(jwkSetUri)
.build();
// Validador de issuer: rechaza tokens de otros realms/IdPs
OAuth2TokenValidator<Jwt> issuerValidator =
JwtValidators.createDefaultWithIssuer(issuerUri);
// Validador de audience: solo acepta tokens dirigidos a este cliente
OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
JwtClaimNames.AUD,
aud -> aud != null && aud.contains(EXPECTED_AUDIENCE)
);
// Combina ambos validadores: ambos deben pasar
var combinedValidator = new org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator<>(
issuerValidator,
audienceValidator
);
decoder.setJwtValidator(combinedValidator);
return decoder;
}
@Bean
public Converter<Jwt, Mono<AbstractAuthenticationToken>> jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(this::extractGrantedAuthorities);
// Nombre del principal: preferred_username es el username en Keycloak
converter.setPrincipalClaimName("preferred_username");
return new ReactiveJwtAuthenticationConverterAdapter(converter);
}
@SuppressWarnings("unchecked")
private Collection<GrantedAuthority> extractGrantedAuthorities(Jwt jwt) {
// Roles a nivel de realm
Stream<GrantedAuthority> realmRoles = Stream.empty();
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess != null && realmAccess.containsKey("roles")) {
realmRoles = ((List<String>) realmAccess.get("roles")).stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()));
}
// Roles del cliente específico
Stream<GrantedAuthority> clientRoles = Stream.empty();
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
if (resourceAccess != null && resourceAccess.containsKey(EXPECTED_AUDIENCE)) {
Map<String, Object> clientAccess =
(Map<String, Object>) resourceAccess.get(EXPECTED_AUDIENCE);
List<String> roles = (List<String>) clientAccess.getOrDefault("roles", List.of());
clientRoles = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()));
}
return Stream.concat(realmRoles, clientRoles).collect(Collectors.toList());
}
}
Paso 4 — Mapeo de claims a authorities¶
Keycloak emite roles en dos claims distintos según cómo estén configurados:
| Claim en JWT | Tipo de rol | Ejemplo | Dónde configurar en Keycloak |
|---|---|---|---|
realm_access.roles |
Roles de realm (globales) | ["ROLE_USER", "ROLE_ADMIN"] |
Realm → Roles → Asignar a usuario |
resource_access.<clientId>.roles |
Roles de cliente (específicos) | ["ROLE_PARTNER_READ"] |
Client → Roles → Asignar a usuario |
scope |
Scopes OAuth2 | "openid profile billing:read" |
Client → Client Scopes |
El extractGrantedAuthorities del paso anterior extrae ambos tipos y los convierte a SimpleGrantedAuthority con prefijo ROLE_ para compatibilidad con hasRole() en Spring Security.
Scopes vs Roles
Los scopes OAuth2 (scope claim) controlan qué información puede ver el cliente. Los roles (realm_access.roles) controlan qué operaciones puede ejecutar el usuario. Para @PreAuthorize, usa roles. Para decidir qué datos incluir en la respuesta, considera los scopes.
Paso 5 — Anotaciones @PreAuthorize¶
Con @EnableReactiveMethodSecurity activo, puedes proteger métodos individuales:
package com.empresa.partnerapi.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/partners")
public class PartnerController {
private final PartnerService partnerService;
public PartnerController(PartnerService partnerService) {
this.partnerService = partnerService;
}
/**
* Lectura de lista: cualquier rol PARTNER o ADMIN.
* @AuthenticationPrincipal Jwt inyecta el JWT directamente sin acceder
* a ReactiveSecurityContextHolder manualmente.
*/
@GetMapping
@PreAuthorize("hasAnyRole('PARTNER_READ', 'PARTNER_ADMIN', 'ADMIN')")
public Flux<PartnerResponse> listPartners(@AuthenticationPrincipal Jwt jwt) {
String requestingUser = jwt.getClaimAsString("preferred_username");
return partnerService.findAll(requestingUser);
}
/**
* Lectura de un partner específico — también accesible por el propio partner
* usando SpEL para comparar el subject del token con el ID del recurso.
*/
@GetMapping("/{partnerId}")
@PreAuthorize("hasRole('ADMIN') or " +
"(hasRole('PARTNER_READ') and #jwt.subject == #partnerId)")
public Mono<PartnerResponse> getPartner(
@PathVariable String partnerId,
@AuthenticationPrincipal Jwt jwt) {
return partnerService.findById(partnerId);
}
/**
* Creación: solo PARTNER_ADMIN o ADMIN.
*/
@PostMapping
@PreAuthorize("hasAnyRole('PARTNER_ADMIN', 'ADMIN')")
public Mono<PartnerResponse> createPartner(
@RequestBody PartnerRequest request,
@AuthenticationPrincipal Jwt jwt) {
return partnerService.create(request, jwt.getClaimAsString("preferred_username"));
}
/**
* Eliminación: solo ADMIN con realm role.
*/
@DeleteMapping("/{partnerId}")
@PreAuthorize("hasRole('ADMIN')")
public Mono<Void> deletePartner(@PathVariable String partnerId) {
return partnerService.delete(partnerId);
}
}
Paso 6 — Endpoint protegido completo con ejemplo¶
// DTO de respuesta
public record PartnerResponse(
String id,
String name,
String email,
String status,
List<String> permissions
) {}
// DTO de request
public record PartnerRequest(
String name,
String email,
String contractType
) {}
// Servicio (stub)
@Service
public class PartnerService {
public Flux<PartnerResponse> findAll(String requestingUser) {
// En producción: consulta reactiva a base de datos con R2DBC
return Flux.just(
new PartnerResponse("p001", "Acme Corp", "acme@corp.com", "ACTIVE",
List.of("billing:read", "orders:write")),
new PartnerResponse("p002", "Beta Ltd", "beta@ltd.com", "PENDING",
List.of("billing:read"))
);
}
public Mono<PartnerResponse> findById(String partnerId) {
return Mono.just(
new PartnerResponse(partnerId, "Acme Corp", "acme@corp.com", "ACTIVE",
List.of("billing:read", "orders:write"))
);
}
public Mono<PartnerResponse> create(PartnerRequest request, String createdBy) {
return Mono.just(
new PartnerResponse(java.util.UUID.randomUUID().toString(),
request.name(), request.email(), "PENDING", List.of())
);
}
public Mono<Void> delete(String partnerId) {
return Mono.empty();
}
}
Paso 7 — Prueba con curl¶
Obtener token de Keycloak (flujo Client Credentials para M2M)¶
# Exporta variables de entorno
export KC_URL="https://auth.empresa.com"
export REALM="realm-partners"
export CLIENT_ID="partner-service-m2m"
export CLIENT_SECRET="tu-client-secret-aqui"
# Solicita token
TOKEN=$(curl -s -X POST \
"${KC_URL}/realms/${REALM}/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d "scope=openid" \
| jq -r '.access_token')
echo "Token obtenido: ${TOKEN:0:50}..."
Llamar al endpoint protegido¶
# GET /api/partners — lista de partners
curl -s -X GET \
"http://localhost:8080/api/partners" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
| jq .
# Respuesta esperada:
# [
# { "id": "p001", "name": "Acme Corp", "email": "acme@corp.com", ... },
# { "id": "p002", "name": "Beta Ltd", "email": "beta@ltd.com", ... }
# ]
Probar acceso denegado¶
# Sin token — debe retornar 401
curl -s -o /dev/null -w "%{http_code}" \
"http://localhost:8080/api/partners"
# Salida: 401
# Token expirado o inválido — debe retornar 401
curl -s -o /dev/null -w "%{http_code}" \
"http://localhost:8080/api/partners" \
-H "Authorization: Bearer token-invalido"
# Salida: 401
# Token válido pero sin rol ADMIN — intento de acceso a /api/admin — debe retornar 403
curl -s -o /dev/null -w "%{http_code}" \
"http://localhost:8080/api/admin/users" \
-H "Authorization: Bearer ${TOKEN}"
# Salida: 403
Inspeccionar el JWT recibido¶
# Decodifica el JWT (sin verificar firma — solo para debugging)
echo "${TOKEN}" | cut -d. -f2 | base64 --decode 2>/dev/null | jq .
# Campos relevantes a verificar:
# {
# "iss": "https://auth.empresa.com/realms/realm-partners",
# "sub": "uuid-del-cliente",
# "aud": ["partner-portal-app"],
# "exp": 1753574400,
# "iat": 1753570800,
# "azp": "partner-service-m2m",
# "realm_access": { "roles": ["PARTNER_READ"] },
# "scope": "openid"
# }
Verificación de salud del Resource Server¶
# Health check del servicio
curl -s http://localhost:8081/actuator/health | jq .
# Respuesta esperada en estado saludable:
# {
# "status": "UP",
# "components": {
# "diskSpace": { "status": "UP" },
# "ping": { "status": "UP" }
# }
# }
# Métricas en formato Prometheus
curl -s http://localhost:8081/actuator/prometheus | grep http_server_requests
Troubleshooting — error 401 con token válido
Si obtienes 401 con un token aparentemente válido:
1. Verifica que iss en el token coincide exactamente con issuer-uri en application.yml (incluyendo trailing slash o su ausencia).
2. Verifica que aud en el token contiene el valor configurado en EXPECTED_AUDIENCE.
3. Activa logging.level.org.springframework.security: TRACE para ver el motivo exacto del rechazo.
4. Comprueba que el reloj del servidor no está desfasado (el claim exp se valida contra System.currentTimeMillis()).