Saltar a contenido

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-partners o realm-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()).