Saltar a contenido

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.