Secuencia — Validación de JWT¶
La validación de JWT es el mecanismo central que permite a los Resource Servers verificar la autenticidad e integridad de los tokens sin necesidad de consultar Keycloak en cada petición. Spring Security 7 con WebFlux realiza esta validación de forma reactiva, combinando caché de claves públicas (JWKS) con verificación local de firma y claims. Este documento describe el flujo completo, incluyendo rotación de claves y manejo de errores.
Diagrama de secuencia: flujo nominal¶
sequenceDiagram
autonumber
actor Cliente as Cliente<br/>(SPA / Servicio)
participant Traefik as Traefik v3<br/>:443
participant RS as Resource Server<br/>(Spring WebFlux API)
participant JWKS as JWKS Endpoint<br/>Keycloak 26
participant Cache as JWKS Local Cache<br/>(Caffeine / TTL 5min)
Note over Cliente,RS: Petición con Bearer token
Cliente->>Traefik: GET /api/v1/resource<br/>Authorization: Bearer eyJhbGci...
Traefik->>RS: Proxy (TLS terminado, header intacto)
Note over RS: Extracción del header JWT sin verificar
RS->>RS: Parsea JWT header sin verificar:<br/>{"alg":"RS256","kid":"key-2024","typ":"JWT"}
RS->>RS: Extrae kid = "key-2024"
Note over RS,Cache: Búsqueda de clave pública en caché
RS->>Cache: GET jwks[kid="key-2024"]
alt Clave en caché (TTL no expirado)
Cache-->>RS: RSA Public Key (cached)
Note over RS: Evita llamada a Keycloak
else Clave no en caché o TTL expirado
Note over RS,JWKS: Descarga de claves públicas
RS->>JWKS: GET /realms/realm-partners/protocol/openid-connect/certs
JWKS-->>RS: JSON Web Key Set (JWKS)<br/>{"keys":[{"kid":"key-2024","kty":"RSA","use":"sig","n":"...","e":"AQAB"}]}
RS->>Cache: PUT jwks[kid="key-2024"] TTL=5min
Cache-->>RS: OK, clave almacenada
end
Note over RS: Verificación criptográfica de firma
RS->>RS: Verifica firma RS256:<br/>signature = RSA_verify(header.payload, public_key)
alt Firma inválida
RS-->>Traefik: 401 Unauthorized<br/>{"error":"invalid_token","error_description":"Signature verification failed"}
Traefik-->>Cliente: 401 Unauthorized
end
Note over RS: Verificación de claims estándar
RS->>RS: Verifica iss == "https://kc.empresa.com/realms/realm-partners"
RS->>RS: Verifica exp > now() (con tolerancia de 5s de clock skew)
RS->>RS: Verifica nbf <= now() (si presente)
RS->>RS: Verifica aud contiene "partner-api"
alt Algún claim inválido
RS-->>Traefik: 401 Unauthorized<br/>{"error":"invalid_token","error_description":"Token expired" | "Invalid issuer" | "Invalid audience"}
Traefik-->>Cliente: 401 Unauthorized
end
Note over RS: Verificación de autorización (roles/scopes)
RS->>RS: Extrae roles de resource_access["partner-api"].roles<br/>Extrae scopes del claim "scope"<br/>Evalúa @PreAuthorize("hasRole('data-reader')")
alt No tiene permisos suficientes
RS-->>Traefik: 403 Forbidden<br/>{"error":"insufficient_scope"}
Traefik-->>Cliente: 403 Forbidden
end
RS-->>Traefik: 200 OK — Recurso solicitado
Traefik-->>Cliente: 200 OK — Datos
Diagrama de secuencia: rotación de claves¶
sequenceDiagram
autonumber
participant RS as Resource Server
participant Cache as JWKS Cache
participant JWKS as Keycloak JWKS
Note over RS: El admin rota claves en Keycloak<br/>Nueva clave kid="key-2025", vieja kid="key-2024" en periodo de gracia
RS->>Cache: GET jwks[kid="key-2024"]
Cache-->>RS: Clave antigua (TTL no expirado, aún en caché)
Note over RS: Tokens firmados con key-2024 siguen siendo válidos<br/>mientras estén en el periodo de gracia de Keycloak
Note over RS: Nuevo token llega firmado con kid="key-2025"
RS->>Cache: GET jwks[kid="key-2025"]
Cache-->>RS: Cache MISS — kid desconocido
Note over RS: Estrategia: force-refresh del JWKS
RS->>JWKS: GET /realms/realm-partners/protocol/openid-connect/certs
JWKS-->>RS: JWKS con kid="key-2024" y kid="key-2025"
RS->>Cache: PUT jwks[kid="key-2024"] TTL=5min
RS->>Cache: PUT jwks[kid="key-2025"] TTL=5min
RS->>RS: Verifica firma con kid="key-2025" — OK
RS-->>RS: Token válido, procesa petición
Estructura del JWT validado¶
{
"header": {
"alg": "RS256",
"typ": "JWT",
"kid": "key-2024"
},
"payload": {
"iss": "https://kc.empresa.com/realms/realm-partners",
"sub": "user-uuid-1234-5678-abcd",
"aud": ["partner-api", "account"],
"exp": 1722000300,
"iat": 1722000000,
"nbf": 1722000000,
"jti": "unique-jwt-id-abcd1234",
"azp": "partner-portal-app",
"session_state": "3a7b8c2d-1234-5678-abcd",
"scope": "openid profile email",
"realm_access": {
"roles": ["partner-user", "data-reader"]
},
"resource_access": {
"partner-api": {
"roles": ["data-reader"]
}
},
"name": "Juan García",
"email": "juan.garcia@partner.com",
"preferred_username": "juan.garcia",
"email_verified": true
}
}
Claims verificados y su propósito¶
| Claim | Valor esperado | Verificación | Consecuencia si falla |
|---|---|---|---|
alg (header) |
RS256 |
Spring Security rechaza otros algoritmos | 401 — algoritmo no permitido |
iss |
https://kc.empresa.com/realms/realm-partners |
Comparación exacta de string | 401 — emisor inválido |
exp |
Timestamp futuro | exp > now() + clockSkewSeconds |
401 — token expirado |
nbf |
Timestamp pasado (si presente) | nbf <= now() |
401 — token no válido aún |
aud |
Contiene partner-api |
El audience del RS debe estar en el claim | 401 — audience incorrecto |
sub |
UUID no vacío | Presente y no nulo | 401 — sujeto ausente |
jti |
Único por token | Opcional: verificar contra lista de tokens revocados | 401 — token revocado |
scope |
Contiene scopes requeridos | @PreAuthorize("hasAuthority('SCOPE_api:read')") |
403 — scope insuficiente |
| Roles | Rol requerido presente | @PreAuthorize("hasRole('data-reader')") |
403 — sin autorización |
Configuración en Spring Security 7 + WebFlux¶
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class ResourceServerSecurityConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
@Bean
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/actuator/health", "/actuator/info").permitAll()
.pathMatchers("/api/v1/partners/**").hasRole("partner-user")
.pathMatchers("/api/v1/admin/**").hasRole("admin")
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtDecoder(reactiveJwtDecoder())
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.build();
}
@Bean
public ReactiveJwtDecoder reactiveJwtDecoder() {
// NimbusReactiveJwtDecoder con caché de JWKS integrada
var decoder = NimbusReactiveJwtDecoder
.withIssuerLocation(issuerUri)
.build();
// Validadores adicionales más allá de los defaults
var validators = new ArrayList<OAuth2TokenValidator<Jwt>>();
validators.add(new JwtTimestampValidator(Duration.ofSeconds(5))); // clock skew
validators.add(new JwtIssuerValidator(issuerUri));
validators.add(new JwtClaimValidator<List<String>>("aud",
aud -> aud != null && aud.contains("partner-api")));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
return decoder;
}
@Bean
public Converter<Jwt, Mono<AbstractAuthenticationToken>> jwtAuthenticationConverter() {
var converter = new ReactiveJwtAuthenticationConverterAdapter(
new KeycloakJwtAuthenticationConverter()
);
return converter;
}
}
// Conversor de roles de Keycloak al formato de Spring Security
@Component
public class KeycloakJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
private static final String RESOURCE_ACCESS_CLAIM = "resource_access";
private static final String REALM_ACCESS_CLAIM = "realm_access";
private static final String ROLES_CLAIM = "roles";
private static final String PARTNER_API_RESOURCE = "partner-api";
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
var authorities = extractAuthorities(jwt);
return new JwtAuthenticationToken(jwt, authorities, jwt.getClaimAsString("preferred_username"));
}
private Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
var authorities = new ArrayList<GrantedAuthority>();
// Roles del realm (globales)
Optional.ofNullable(jwt.getClaimAsMap(REALM_ACCESS_CLAIM))
.map(m -> (List<?>) m.get(ROLES_CLAIM))
.ifPresent(roles -> roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.forEach(authorities::add));
// Roles del recurso específico (partner-api)
Optional.ofNullable(jwt.getClaimAsMap(RESOURCE_ACCESS_CLAIM))
.map(m -> (Map<?, ?>) m.get(PARTNER_API_RESOURCE))
.map(m -> (List<?>) m.get(ROLES_CLAIM))
.ifPresent(roles -> roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.forEach(authorities::add));
// Scopes OAuth2
Optional.ofNullable(jwt.getClaimAsString("scope"))
.ifPresent(scope -> Arrays.stream(scope.split(" "))
.map(s -> new SimpleGrantedAuthority("SCOPE_" + s))
.forEach(authorities::add));
return authorities;
}
}
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://kc.empresa.com/realms/realm-partners
# jwk-set-uri: https://kc.empresa.com/realms/realm-partners/protocol/openid-connect/certs
# jwk-set-cache-lifespan: 5m (default)
# jwk-set-cache-refresh-rate: 1m (force-refresh para nuevos kid)
Manejo de errores y respuestas¶
// CustomAuthenticationEntryPoint para respuestas de error estructuradas
@Component
public class OAuth2AuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException ex) {
var response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
var body = Map.of(
"error", "unauthorized",
"error_description", resolveDescription(ex),
"timestamp", Instant.now().toString()
);
return response.writeWith(
Mono.fromCallable(() -> objectMapper.writeValueAsBytes(body))
.map(bytes -> response.bufferFactory().wrap(bytes))
);
}
private String resolveDescription(AuthenticationException ex) {
if (ex.getCause() instanceof JwtValidationException jve) {
return jve.getErrors().stream()
.map(OAuth2Error::getDescription)
.collect(joining("; "));
}
return "Token de autenticación inválido o ausente";
}
}
Clock skew entre contenedores
En entornos Docker Compose, asegúrate de que todos los contenedores (Keycloak, APIs) sincronicen el reloj del sistema con NTP. Una diferencia de más de 5 segundos puede causar que tokens válidos sean rechazados por exp o nbf incorrectos. Usa chrony en el host VPS.
Caché de JWKS y rotación de claves
Spring Security 7 implementa un mecanismo de force-refresh: si recibe un token con un kid no conocido en caché, descarga nuevamente el JWKS antes de fallar. Esto permite rotaciones de claves en Keycloak sin tiempo de inactividad, siempre que el periodo de gracia de la clave antigua supere el TTL de la caché (5 minutos por defecto).
Validación local vs introspección remota
La validación local de JWT (verificar firma + claims) es la estrategia recomendada por su bajo latencia. La introspección remota (llamar al /introspect endpoint de Keycloak por cada petición) ofrece revocación inmediata pero añade latencia y carga a Keycloak. Usa introspección solo para tokens de alto riesgo donde la revocación inmediata sea crítica.