JWT con Spring Security¶
Spring Security procesa automáticamente los JWT en cada request HTTP. El token se extrae del header Authorization: Bearer, se valida la firma con las claves públicas de Keycloak (JWKS), y se construye el SecurityContext con los claims del token.
Flujo de procesamiento¶
sequenceDiagram
participant R as Request
participant F as BearerTokenAuthenticationFilter
participant D as ReactiveJwtDecoder
participant K as Keycloak JWKS
participant C as Converter (roles)
participant S as SecurityContext
R->>F: Authorization: Bearer eyJ...
F->>F: Extraer token del header
F->>D: decode(tokenString)
D->>D: Verificar formato JWT (header.payload.signature)
D->>K: GET /jwks (si kid no está en caché)
K-->>D: Claves públicas RSA
D->>D: Verificar firma RSA\nVerificar exp, iss, nbf
D-->>F: Jwt object con claims
F->>C: convert(Jwt) → Mono<Authentication>
C->>C: Extraer realm_access.roles\nCrear GrantedAuthority por cada rol
C-->>S: JwtAuthenticationToken poblado
S-->>R: Request continúa autenticada
Configuración del JwtDecoder¶
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
# Opción 1: Spring descarga el JWKS automáticamente desde el discovery
issuer-uri: https://auth.example.com/realms/realm-internal
# Opción 2: URI explícita del JWKS (más rápido al arrancar)
# jwk-set-uri: https://auth.example.com/realms/realm-internal/protocol/openid-connect/certs
# Opción 3: Validar audience (recomendado para mayor seguridad)
# audiences: my-api-name
JwtDecoder personalizado con validaciones adicionales¶
@Configuration
public class JwtConfig {
@Bean
public ReactiveJwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuerUri,
@Value("${app.security.expected-audience:}") String expectedAudience) {
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
.withJwkSetUri(jwkSetUri)
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
// Validadores adicionales
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
validators.add(new JwtTimestampValidator());
validators.add(new JwtIssuerValidator(issuerUri));
if (StringUtils.hasText(expectedAudience)) {
validators.add(new JwtClaimValidator<List<String>>(
JwtClaimNames.AUD,
aud -> aud != null && aud.contains(expectedAudience)
));
}
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
return decoder;
}
}
Extracción de roles de Keycloak¶
Keycloak incluye los roles en realm_access.roles, no en el claim estándar roles. Spring Security no los extrae automáticamente; hay que configurar un converter:
@Bean
public Converter<Jwt, Mono<AbstractAuthenticationToken>> jwtAuthenticationConverter() {
return jwt -> {
// Roles de realm
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
List<String> realmRoles = realmAccess != null
? (List<String>) realmAccess.getOrDefault("roles", List.of())
: List.of();
// Roles de cliente específico (resource_access.<client-id>.roles)
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
List<String> clientRoles = Optional.ofNullable(resourceAccess)
.map(ra -> (Map<String, Object>) ra.get("mi-api"))
.map(clientAccess -> (List<String>) clientAccess.get("roles"))
.orElse(List.of());
// Scopes del token
String scopeString = jwt.getClaimAsString("scope");
List<String> scopes = scopeString != null
? Arrays.asList(scopeString.split(" "))
: List.of();
// Construir authorities
Stream<GrantedAuthority> realmAuthorities = realmRoles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.toUpperCase()));
Stream<GrantedAuthority> clientAuthorities = clientRoles.stream()
.map(r -> new SimpleGrantedAuthority("CLIENT_ROLE_" + r.toUpperCase()));
Stream<GrantedAuthority> scopeAuthorities = scopes.stream()
.map(s -> new SimpleGrantedAuthority("SCOPE_" + s));
List<GrantedAuthority> authorities = Stream.of(realmAuthorities, clientAuthorities, scopeAuthorities)
.flatMap(Function.identity())
.collect(Collectors.toList());
String principalName = jwt.getClaimAsString("preferred_username");
return Mono.just(new JwtAuthenticationToken(jwt, authorities, principalName));
};
}
Claims más usados de un JWT de Keycloak¶
@GetMapping("/debug/token")
@PreAuthorize("hasRole('DEVELOPER')")
public Mono<Map<String, Object>> debugToken(@AuthenticationPrincipal Jwt jwt) {
return Mono.just(Map.of(
// Claims estándar
"sub", jwt.getSubject(),
"iss", jwt.getIssuer().toString(),
"exp", jwt.getExpiresAt(),
"iat", jwt.getIssuedAt(),
// Claims de Keycloak
"preferred_username", jwt.getClaimAsString("preferred_username"),
"email", jwt.getClaimAsString("email"),
"name", jwt.getClaimAsString("name"),
"email_verified", jwt.getClaimAsBoolean("email_verified"),
"realm_access", jwt.getClaimAsMap("realm_access"),
"azp", jwt.getClaimAsString("azp"), // authorized party (client_id)
"session_state", jwt.getClaimAsString("session_state"),
"scope", jwt.getClaimAsString("scope")
));
}
Inspeccionar un JWT manualmente¶
# Decodificar un JWT (sin verificar firma — solo para debugging)
TOKEN="eyJhbGci..."
# Header
echo $TOKEN | cut -d. -f1 | base64 -d 2>/dev/null | jq .
# Payload
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .
# Alternativa: usar jwt.io en el navegador
Cache de JWKS — comportamiento automático
Spring Security cachea las JWKs en memoria. Si Keycloak rota las claves de firma, el decoder detecta un kid desconocido en el JWT y automáticamente descarga las JWKs actualizadas. No es necesario reiniciar el servicio.
RS256 es obligatorio, HS256 es inseguro en este contexto
Keycloak firma con RS256 (clave asimétrica) por defecto. No cambies a HS256 (clave simétrica) en producción: requeriría compartir el secret entre Keycloak y cada servicio, lo que viola el principio de mínimo privilegio y elimina la posibilidad de rotación independiente de claves.