Saltar a contenido

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 .

Configurar audience validation

Por defecto Spring Security no valida el claim aud. Para mayor seguridad, configura la audiencia esperada:

spring.security.oauth2.resourceserver.jwt.audiences: mi-resource-server
Y en Keycloak agrega un mapper de tipo "Audience" al cliente para incluir el claim aud.