Validación de JWT de AWS Cognito en Spring Boot con Kotlin y Gradle

Fecha de publicación: 2025-01-25
Spring Boot y AWS Cognito
En este tutorial aprenderás cómo configurar Spring Boot con Kotlin y Gradle para validar tokens JWT de AWS Cognito y proteger tus endpoints. Nos enfocaremos únicamente en la validación de tokens, asumiendo que el login se maneja desde el frontend o una aplicación externa.
Esta implementación es ideal cuando:
  • Tu frontend maneja el login con Cognito directamente
  • Tienes una aplicación móvil que ya obtiene tokens de Cognito
  • Quieres un backend stateless que solo valide y proteja endpoints
  • Necesitas una configuración simple y minimalista
¡Empecemos con la implementación!

🛠️ Paso 1: Configuración inicial del proyecto

Primero, agrega las dependencias necesarias en tu build.gradle.kts:
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.security:spring-security-oauth2-resource-server")
    implementation("org.springframework.security:spring-security-oauth2-jose")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    
    // Para Kotlin
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
}

☁️ Paso 2: Configuración de AWS Cognito

Configura Cognito en tu application.yml:
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX
Nota: Reemplaza us-east-1_XXXXXXXXX con el ID real de tu User Pool de Cognito. Puedes encontrarlo en la consola de AWS Cognito en la sección "User pools".

🔐 Paso 3: Configuración de Spring Security

Crea la configuración de seguridad para validar JWT tokens de Cognito:
@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .csrf { it.disable() }
            .authorizeHttpRequests { auth ->
                auth
                    .requestMatchers("/api/public/**").permitAll()
                    .anyRequest().authenticated()
            }
            .oauth2ResourceServer { oauth2 ->
                oauth2.jwt()
            }
            .sessionManagement { 
                it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) 
            }
            .build()
    }
}
Con esta configuración simple, Spring Boot automáticamente:
  • Descarga las claves públicas de Cognito
  • Valida la firma de los JWT tokens
  • Verifica que el token no haya expirado
  • Extrae los claims del usuario del token

👥 Alternativa: Configuración con Roles

Si necesitas autorización basada en roles, puedes configurar Spring Security para usar los grupos de Cognito como roles de autorización:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfigWithRoles {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .csrf { it.disable() }
            .authorizeHttpRequests { auth ->
                auth
                    .requestMatchers("/api/public/**").permitAll()
                    .requestMatchers("/api/admin/**").hasRole("ADMIN")
                    .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
                    .anyRequest().authenticated()
            }
            .oauth2ResourceServer { oauth2 ->
                oauth2.jwt { jwt ->
                    jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
                }
            }
            .sessionManagement { 
                it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) 
            }
            .build()
    }

    @Bean
    fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
        val authoritiesConverter = JwtGrantedAuthoritiesConverter()
        authoritiesConverter.setAuthorityPrefix("ROLE_")
        authoritiesConverter.setAuthoritiesClaimName("cognito:groups")

        val authenticationConverter = JwtAuthenticationConverter()
        authenticationConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter)
        return authenticationConverter
    }
}
Explicación del código:
  • @EnableMethodSecurity: Habilita anotaciones como @PreAuthorize
  • hasRole("ADMIN"): Solo usuarios con rol ADMIN pueden acceder
  • hasAnyRole("USER", "ADMIN"): Usuarios con cualquiera de estos roles
  • cognito:groups: Claim del JWT que contiene los grupos del usuario
  • ROLE_: Prefijo automático que agrega Spring Security

Configuración en AWS Cognito

Para usar roles, necesitas configurar grupos en tu User Pool de Cognito:
# Crear grupos en Cognito usando AWS CLI
aws cognito-idp create-group \
    --group-name "ADMIN" \
    --user-pool-id us-east-1_XXXXXXXXX \
    --description "Administrators"

aws cognito-idp create-group \
    --group-name "USER" \
    --user-pool-id us-east-1_XXXXXXXXX \
    --description "Regular users"

# Asignar usuario a un grupo
aws cognito-idp admin-add-user-to-group \
    --user-pool-id us-east-1_XXXXXXXXX \
    --username "test@example.com" \
    --group-name "ADMIN"

🛡️ Paso 4: Endpoint protegido de ejemplo

Crea endpoints para mostrar diferentes niveles de acceso: públicos, autenticados y con roles específicos:
@RestController
@RequestMapping("/api/public")
class PublicController {

    @GetMapping("/health")
    fun healthCheck(): ResponseEntity<Map<String, String>> {
        return ResponseEntity.ok(mapOf(
            "status" to "OK",
            "message" to "Endpoint público - no requiere autenticación",
            "timestamp" to Instant.now().toString()
        ))
    }
}

@RestController
@RequestMapping("/api/protected")
class ProtectedController {

    @GetMapping("/profile")
    fun getUserProfile(authentication: JwtAuthenticationToken): ResponseEntity<Map<String, Any>> {
        val jwt = authentication.token
        
        return ResponseEntity.ok(mapOf(
            "userId" to jwt.getClaimAsString("sub"),
            "email" to jwt.getClaimAsString("email"),
            "username" to jwt.getClaimAsString("cognito:username"),
            "groups" to jwt.getClaimAsStringList("cognito:groups"),
            "tokenType" to jwt.getClaimAsString("token_use"),
            "issuedAt" to jwt.issuedAt,
            "expiresAt" to jwt.expiresAt
        ))
    }
}

@RestController
@RequestMapping("/api/admin")
class AdminController {

    @GetMapping("/dashboard")
    @PreAuthorize("hasRole('ADMIN')")
    fun adminDashboard(): ResponseEntity<Map<String, String>> {
        return ResponseEntity.ok(mapOf(
            "message" to "Panel de administración - solo ADMIN",
            "timestamp" to Instant.now().toString()
        ))
    }
    
    @GetMapping("/users")
    @PreAuthorize("hasRole('ADMIN')")
    fun getAllUsers(): ResponseEntity<Map<String, Any>> {
        return ResponseEntity.ok(mapOf(
            "users" to listOf("user1@example.com", "user2@example.com"),
            "total" to 2,
            "requiredRole" to "ADMIN"
        ))
    }
}

@RestController
@RequestMapping("/api/user")
class UserController {

    @GetMapping("/data")
    @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
    fun getUserData(authentication: JwtAuthenticationToken): ResponseEntity<Map<String, Any>> {
        val jwt = authentication.token
        return ResponseEntity.ok(mapOf(
            "data" to "Datos del usuario",
            "allowedRoles" to listOf("USER", "ADMIN"),
            "userGroups" to jwt.getClaimAsStringList("cognito:groups")
        ))
    }
}

🧪 Paso 5: Prueba tu implementación

Para probar tu implementación necesitas un token JWT válido de Cognito. Puedes obtenerlo de varias formas:

Opción 1: Usar AWS CLI

# Instalar AWS CLI y configurar credenciales
aws configure

# Autenticar usuario y obtener tokens
aws cognito-idp initiate-auth \
    --auth-flow USER_PASSWORD_AUTH \
    --client-id your-client-id \
    --auth-parameters USERNAME=test@example.com,PASSWORD=TempPassword123!

Opción 2: Probar con curl

# 1. Endpoint público (sin token)
curl -X GET http://localhost:8080/api/public/health

# 2. Endpoint protegido (requiere token válido)
curl -X GET http://localhost:8080/api/protected/profile \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsIn..."

# 3. Endpoint para usuarios (requiere rol USER o ADMIN)
curl -X GET http://localhost:8080/api/user/data \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsIn..."

# 4. Endpoint solo para administradores (requiere rol ADMIN)
curl -X GET http://localhost:8080/api/admin/dashboard \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsIn..."

# 5. Listar usuarios (solo ADMIN)
curl -X GET http://localhost:8080/api/admin/users \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsIn..."

Respuestas esperadas:

// Sin token en endpoint protegido:
HTTP 401 Unauthorized

// Con token válido pero sin rol requerido:
HTTP 403 Forbidden
{
  "error": "Access Denied"
}

// Con token y rol correcto:
HTTP 200 OK
{
  "userId": "12345-abcde-67890",
  "email": "test@example.com",
  "groups": ["ADMIN"],
  "data": "Información del endpoint"
}

⚙️ Paso 6: Configuración avanzada (opcional)

Para una implementación más robusta, puedes agregar configuraciones adicionales:
// Imports necesarios:
// import java.time.Instant
// import javax.servlet.http.HttpServletResponse
// import org.springframework.security.web.access.AccessDeniedHandler
// import org.springframework.security.web.AuthenticationEntryPoint

@Configuration
class CognitoAdvancedConfig {

    @Bean
    fun corsConfigurationSource(): CorsConfigurationSource {
        val configuration = CorsConfiguration()
        configuration.allowedOriginPatterns = listOf("*")
        configuration.allowedMethods = listOf("*")
        configuration.allowedHeaders = listOf("*")
        configuration.allowCredentials = true
        
        val source = UrlBasedCorsConfigurationSource()
        source.registerCorsConfiguration("/**", configuration)
        return source
    }

    @Bean
    fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
        val authoritiesConverter = JwtGrantedAuthoritiesConverter()
        authoritiesConverter.setAuthorityPrefix("ROLE_")
        authoritiesConverter.setAuthoritiesClaimName("cognito:groups")

        val authenticationConverter = JwtAuthenticationConverter()
        authenticationConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter)
        
        // Opcional: personalizar el nombre del principal
        authenticationConverter.setPrincipalClaimName("email")
        
        return authenticationConverter
    }

    // Opcional: Manejo personalizado de errores de autorización
    @Bean
    fun accessDeniedHandler(): AccessDeniedHandler {
        return AccessDeniedHandler { _, response, _ ->
            response.status = HttpServletResponse.SC_FORBIDDEN
            response.contentType = "application/json"
            response.writer.write("""
                {
                    "error": "Access Denied",
                    "message": "No tienes permisos para acceder a este recurso",
                    "timestamp": "${Instant.now()}"
                }
            """.trimIndent())
        }
    }

    // Opcional: Manejo personalizado de errores de autenticación
    @Bean 
    fun authenticationEntryPoint(): AuthenticationEntryPoint {
        return AuthenticationEntryPoint { _, response, _ ->
            response.status = HttpServletResponse.SC_UNAUTHORIZED
            response.contentType = "application/json"
            response.writer.write("""
                {
                    "error": "Unauthorized",
                    "message": "Token JWT requerido",
                    "timestamp": "${Instant.now()}"
                }
            """.trimIndent())
        }
    }
}

🐛 Debugging: Problemas comunes con roles

Si tienes problemas con la autorización basada en roles, verifica:
// Endpoint para debug: ver qué authorities tiene el usuario
@GetMapping("/debug/authorities")
fun debugAuthorities(authentication: JwtAuthenticationToken): ResponseEntity<Map<String, Any>> {
    val jwt = authentication.token
    return ResponseEntity.ok(mapOf(
        "principal" to authentication.name,
        "authorities" to authentication.authorities.map { it.authority },
        "groups" to jwt.getClaimAsStringList("cognito:groups"),
        "allClaims" to jwt.claims
    ))
}
Checklist de problemas comunes:
  • ¿El usuario está asignado al grupo correcto en Cognito?
  • ¿El claim cognito:groups aparece en el JWT?
  • ¿El prefijo ROLE_ se está agregando correctamente?
  • ¿El nombre del grupo coincide exactamente? (case-sensitive)
  • ¿Habilitaste @EnableMethodSecurity para usar @PreAuthorize?

✅ Ventajas de esta implementación

  • Simplicidad: Solo unas pocas líneas de configuración
  • Seguridad automática: Validación JWT sin código manual
  • Stateless: Perfecto para microservicios y aplicaciones distribuidas
  • Integración nativa: Spring Security maneja todo automáticamente
  • Separación de responsabilidades: Backend solo valida, frontend maneja login
  • Escalabilidad: Sin gestión de sesiones en el servidor
  • Compatibilidad: Funciona con cualquier cliente que envíe JWT tokens

🚀 Próximos pasos

Una vez que tengas la validación de tokens y roles funcionando, puedes explorar:
  • Implementar roles jerárquicos (admin → user → guest)
  • Agregar autorización a nivel de método con expresiones SpEL
  • Configurar grupos dinámicos usando Lambda triggers en Cognito
  • Implementar logging de acceso y auditoría de roles
  • Agregar validaciones de permisos más granulares por endpoint
  • Configurar cache de roles para mejor performance
  • Implementar autorización condicional basada en atributos del usuario

📌 Conclusión

Esta implementación minimalista de validación JWT con AWS Cognito y Spring Boot te proporciona una base sólida y segura para proteger tus endpoints. Con muy poca configuración obtienes validación automática y robusta de tokens.
La separación de responsabilidades es clave: tu backend se enfoca únicamente en validar tokens y proteger recursos, mientras que la autenticación puede manejarse desde el frontend, aplicaciones móviles o servicios externos.