Cómo implementar JWS y JWK en Spring Security OAuth2

Cómo implementar JSON Web Signature (JWS) y el uso de JSON Web Key (JWK) en aplicaciones Spring Security OAuth2

Introducción
En este tutorial, aprenderemos sobre JSON Web Signature (JWS) y cómo se puede implementar utilizando la especificación JSON Web Key (JWK) en aplicaciones configuradas con Spring Security OAuth2. Aunque Spring está trabajando para migrar todas las características de Spring Security OAuth a Spring Security, esta guía sigue siendo un buen punto de partida para entender los conceptos básicos de estas especificaciones, y resultará útil cuando se implemente en cualquier marco.
Primero, intentaremos comprender los conceptos básicos, como qué son JWS y JWK, su propósito y cómo podemos configurar fácilmente un servidor de recursos para usar esta solución OAuth. Luego, profundizaremos en el análisis de las especificaciones en detalle al analizar qué está haciendo OAuth2 Boot detrás de escena y configuraremos un servidor de autorización para usar JWK.
Comprendiendo la Perspectiva Global de JWS y JWK
Antes de comenzar, es importante entender correctamente algunos conceptos básicos. Es recomendable revisar nuestros artículos sobre OAuth y JWT primero, ya que estos temas no están dentro del alcance de este tutorial.
JWS es una especificación creada por el IETF que describe diferentes mecanismos criptográficos para verificar la integridad de los datos, específicamente los datos en un JSON Web Token (JWT). Define una estructura JSON que contiene la información necesaria para hacerlo.
Es un aspecto clave en la ampliamente utilizada especificación JWT, ya que las declaraciones deben estar firmadas o cifradas para poder considerarse efectivamente seguras. En el primer caso, el JWT se representa como JWS. Si está cifrado, el JWT se codificará en una estructura de JSON Web Encryption (JWE).
El escenario más común al trabajar con OAuth es tener solo JWTs firmados. Esto se debe a que por lo general no necesitamos “ocultar” información, sino simplemente verificar la integridad de los datos.
Por supuesto, ya sea que estemos manejando JWTs firmados o cifrados, necesitamos directrices formales para poder transmitir claves públicas de manera eficiente. Este es el propósito de JWK, una estructura JSON que representa una clave criptográfica, también definida por el IETF.
Muchos proveedores de autenticación ofrecen un punto final “JWK Set”, también definido en las especificaciones. Con él, otras aplicaciones pueden encontrar información sobre claves públicas para procesar JWTs. Por ejemplo, un servidor de recursos usa el campo kid (clave ID) presente en el JWT para encontrar la clave correcta en el conjunto JWK.
Implementando una Solución Usando JWK
Comúnmente, si queremos que nuestra aplicación sirva recursos de manera segura, como usando un protocolo de seguridad estándar como OAuth 2.0, necesitaremos seguir los siguientes pasos:
  • Registrar clientes en un servidor de autorización, ya sea en nuestro propio servicio, o en un proveedor conocido como Okta, Facebook o Github.
  • Estos clientes solicitarán un token de acceso del servidor de autorización, siguiendo cualquiera de las estrategias de OAuth que hayamos configurado.
  • Luego intentarán acceder al recurso presentando el token (en este caso, como un JWT) al servidor de recursos.
  • El servidor de recursos debe verificar que el token no ha sido manipulado revisando su firma, así como validar sus declaraciones.
  • Finalmente, nuestro servidor de recursos recupera el recurso, ahora estando seguros de que el cliente tiene los permisos correctos.
JWK y la Configuración del Servidor de Recursos
Más adelante, veremos cómo configurar nuestro propio servidor de autorización que sirva JWTs y un punto final ‘JWK Set’. En este punto, sin embargo, nos centraremos en el escenario más simple – y probablemente el más común – donde apuntamos a un servidor de autorización existente.
Todo lo que tenemos que hacer es indicar cómo el servicio tiene que validar el token de acceso que recibe, como qué clave pública debe usar para validar la firma del JWT.
Usaremos las características de autoconfiguración de Spring Security OAuth para lograrlo de manera simple y limpia, utilizando solo propiedades de aplicación.
Dependencia de Maven
Necesitamos agregar la dependencia de autoconfiguración de OAuth2 a nuestro archivo pom de la aplicación Spring:
<dependency>
    <groupId>org.springframework.security.oauth.boot</groupId>
    <artifactId>spring-security-oauth2-autoconfigure</artifactId>
    <version>2.1.6.RELEASE</version>
</dependency>
Como de costumbre, podemos verificar la versión más reciente del artefacto en Maven Central. Nota que esta dependencia no está gestionada por Spring Boot, por lo que debemos especificar su versión, la cual debería coincidir con la versión de Spring Boot que estamos utilizando.
Configuración del Servidor de Recursos
A continuación, debemos habilitar las características del servidor de recursos en nuestra aplicación con la anotación @EnableResourceServer:
@SpringBootApplication
@EnableResourceServer
public class ResourceServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(ResourceServerApplication.class, args);
    }
}
Ahora necesitamos indicar cómo nuestra aplicación puede obtener la clave pública necesaria para validar la firma de los JWTs que recibe como tokens Bearer.
OAuth2 Boot ofrece diferentes estrategias para verificar el token. Como mencionamos antes, la mayoría de los servidores de autorización exponen una URI con una colección de claves que otros servicios pueden usar para validar la firma.
Configuaremos el punto final JWK Set de un servidor de autorización local en el que trabajaremos más adelante. Vamos a añadir lo siguiente a nuestro archivo application.properties:
security.oauth2.resource.jwk.key-set-uri=
  http://localhost:8081/sso-auth-server/.well-known/jwks.json
Echaremos un vistazo a otras estrategias mientras analizamos este tema en detalle.
Nota: el nuevo servidor de recursos Spring Security 5.1 solo admite JWTs firmados por JWK como autorización, y Spring Boot también ofrece una propiedad muy similar para configurar el punto final JWK Set:
spring.security.oauth2.resourceserver.jwk-set-uri=
  http://localhost:8081/sso-auth-server/.well-known/jwks.json
Configuraciones de Spring Bajo el Capó
La propiedad que agregamos anteriormente se traduce en la creación de un par de beans de Spring. En concreto, OAuth2 Boot creará:
  • Un JwkTokenStore con la única capacidad de decodificar un JWT y verificar su firma.
  • Una instancia de DefaultTokenServices para usar el TokenStore anterior.
El Punto Final JWK Set en el Servidor de Autorización
Ahora profundizaremos en este tema, analizando algunos aspectos clave de JWK y JWS mientras configuramos un servidor de autorización que emite JWTs y sirve su punto final JWK Set. Recuerde que, dado que Spring Security aún no ofrece funciones para configurar un servidor de autorización, crear uno usando las capacidades de Spring Security OAuth es la única opción en esta etapa. Sin embargo, será compatible con el servidor de recursos de Spring Security.
Habilitando Funciones del Servidor de Autorización
El primer paso es configurar nuestro servidor de autorización para emitir tokens de acceso cuando sean requeridos. También agregaremos la dependencia spring-security-oauth2-autoconfigure como lo hicimos con el servidor de recursos.
Primero, usaremos la anotación @EnableAuthorizationServer para configurar los mecanismos del servidor de autorización OAuth2:
@Configuration
@EnableAuthorizationServer
public class JwkAuthorizationServerConfiguration {
    // ...
}
Y registraremos un cliente OAuth 2.0 usando propiedades:
security.oauth2.client.client-id=bael-client
security.oauth2.client.client-secret=bael-secret
Con esto, nuestra aplicación recuperará cadenas de tokens aleatorios cuando se le solicite con las credenciales correspondientes:
curl bael-client:bael-secret \
  http://localhost:8081/sso-auth-server/oauth/token \
  -d grant_type=client_credentials \
  -d scope=any
Como podemos ver, Spring Security OAuth recupera, por defecto, un valor de cadena aleatoria, no un JWT codificado:
"access_token": "af611028-643f-4477-9319-b5aa8dc9408f"
Emitindo JWTs
Podemos cambiar esto fácilmente creando un bean JwtAccessTokenConverter en el contexto:
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
    return new JwtAccessTokenConverter();
}
Y usándolo en una instancia de JwtTokenStore:
@Bean
public TokenStore tokenStore() {
    return new JwtTokenStore(accessTokenConverter());
}
Con estos cambios, solicitar un nuevo token de acceso significará que obtendremos un JWT, codificado como un JWS, para ser exactos. Podemos identificar fácilmente los JWS; su estructura consta de tres campos (cabezera, carga útil y firma) separados por un punto:
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  .
  eyJzY29wZSI6WyJhbnkiXSwiZXhwIjoxNTYxOTcy...
  .
  XKH70VUHeafHLaUPVXZI9E9pbFxrJ35PqBvrymxtvGI"
Por defecto, Spring firma la cabecera y la carga útil usando un enfoque de código de autenticación de mensajes (MAC). Podemos verificar esto analizando el JWT en alguna de las muchas herramientas en línea de decodificación/verificación de JWT que podemos encontrar.
Si decodificamos el JWT que obtuvimos, veremos que el valor del atributo alg es HS256, lo que indica que se utilizó un algoritmo HMAC-SHA256 para firmar el token.
La Firma Simétrica Predeterminada
La función de hash MAC utiliza la misma clave para firmar el mensaje y para verificar su integridad; es una función de hash simétrica. Por lo tanto, por razones de seguridad, la aplicación no puede compartir públicamente su clave de firma.
Solo para fines académicos, haremos pública la /oauth/token_key del Spring Security OAuth:
security.oauth2.authorization.token-key-access=permitAll()
Y personalizaremos el valor clave de firma cuando configuremos el bean JwtAccessTokenConverter:
converter.setSigningKey("bael");
Para saber exactamente qué clave simétrica se está utilizando. Nota: incluso si no publicamos la clave de firma, configurar una clave de firma débil es una amenaza potencial para ataques de diccionario.
Una vez que sepamos la clave de firma, podemos verificar manualmente la integridad del token utilizando la herramienta en línea que mencionamos antes. La biblioteca Spring Security OAuth también configura un punto final /oauth/check_token que valida y recupera el JWT decodificado.
Este punto final también está configurado con una regla de acceso denyAll() y debe protegerse conscientemente. Para ello, podríamos usar la propiedad security.oauth2.authorization.check-token-access, como hicimos para la clave de token antes.
Alternativas para la Configuración del Servidor de Recursos
Dependiendo de nuestras necesidades de seguridad, podríamos considerar que asegurar uno de los puntos finales mencionados anteriormente – mientras los hacemos accesibles a los Servidores de Recursos – es suficiente. Si es así, entonces podemos dejar el Servidor de Autorización tal como está, y elegir otro enfoque para el Servidor de Recursos.
El Servidor de Recursos esperará que el Servidor de Autorización tenga puntos finales asegurados, así que para empezar, necesitaremos proporcionar las credenciales del cliente, con las mismas propiedades que usamos en el Servidor de Autorización:
security.oauth2.client.client-id=bael-client
security.oauth2.client.client-secret=bael-secret
Luego podemos optar por usar el punto final /oauth/check_token (también conocido como el punto final de introspección) o obtener una única clave de /oauth/token_key:
## Single key URI:
security.oauth2.resource.jwt.key-uri=
  http://localhost:8081/sso-auth-server/oauth/token_key
## Introspection endpoint:
security.oauth2.resource.token-info-uri=
  http://localhost:8081/sso-auth-server/oauth/check_token
Alternativamente, simplemente podemos configurar la clave que se usará para verificar el token en el Servicio de Recursos:
## Verifier Key
security.oauth2.resource.jwt.key-value=bael
Con este enfoque, no habrá interacción con el Servidor de Autorización, aunque esto significa menos flexibilidad en los cambios con la configuración de firma del Token. Al igual que con la estrategia de URI clave, este último enfoque podría recomendarse solo para algoritmos de firma asimétrica.
Creando un Archivo de Almacenamiento de Claves
No olvidemos nuestro objetivo final. Queremos proporcionar un punto final JWK Set como lo hacen la mayoría de los proveedores más conocidos. Si vamos a compartir claves, será mejor que usemos criptografía asimétrica (particularmente, algoritmos de firma digital) para firmar los tokens.
El primer paso hacia esto es crear un archivo de almacenamiento de claves (keystore). Una manera fácil de lograr esto es:
1. Abrir la línea de comandos en el directorio /bin de cualquier JDK o JRE que tengas a mano:
cd $JAVA_HOME/bin
2. Ejecutar el comando keytool, con los parámetros correspondientes:
./keytool -genkeypair \
  -alias bael-oauth-jwt \
  -keyalg RSA \
  -keypass bael-pass \
  -keystore bael-jwt.jks \
  -storepass bael-pass
Nota que aquí utilizamos un algoritmo RSA, que es asimétrico.
3. Responder a las preguntas interactivas y generar el archivo de almacenamiento de claves.
Agregando el Archivo de Almacenamiento de Claves a Nuestra Aplicación
Debemos agregar el archivo de almacenamiento a nuestros recursos del proyecto. Esta es una tarea simple, pero ten en cuenta que es un archivo binario. Eso significa que no se puede filtrar, o se corromperá.
Si estamos usando Maven, una alternativa es poner los archivos de texto en una carpeta separada y configurar el pom.xml en consecuencia:
<build>
    <resources>
        <resource>
            <directory>src/main/resources</directory>
            <filtering>false</filtering>
        </resource>
        <resource>
            <directory>src/main/resources/filtered</directory>
            <filtering>true</filtering>
        </resource>
    </resources>
</build>
Configurando el TokenStore
El siguiente paso es configurar nuestro TokenStore con el par de claves; la pública para validar la integridad y la privada para firmar los tokens.
Crearemos una instancia KeyPair empleando el archivo de almacenamiento que hemos colocado en el classpath, y los parámetros que utilizamos cuando generamos el archivo .jks:
ClassPathResource ksFile =
  new ClassPathResource("bael-jwt.jks");
KeyStoreKeyFactory ksFactory =
  new KeyStoreKeyFactory(ksFile, "bael-pass".toCharArray());
KeyPair keyPair = ksFactory.getKeyPair("bael-oauth-jwt");
Y lo configuraremos en nuestro bean JwtAccessTokenConverter, eliminando cualquier otra configuración:
converter.setKeyPair(keyPair);
Podemos solicitar y decodificar un JWT nuevamente para verificar que el parámetro alg cambió. Si echamos un vistazo a la clave Token, veremos la clave pública obtenida del archivo de almacenamiento.
Es fácilmente identificable por la cabecera de “Encapsulación PEM” de la RFC 1421; la cadena que comienza con “—–BEGIN PUBLIC KEY—–”.
Dependencias del Punto Final JWK Set
La biblioteca Spring Security OAuth no admite JWK de manera predeterminada. Por lo tanto, tendremos que agregar otra dependencia a nuestro proyecto, nimbus-jose-jwt, que proporciona algunas implementaciones básicas de JWK:
<dependency>
    <groupId>com.nimbusds</groupId>
    <artifactId>nimbus-jose-jwt</artifactId>
    <version>7.3</version>
</dependency>
Recuerda que podemos verificar la versión más reciente de la biblioteca usando el motor de búsqueda del repositorio de Maven Central.
Creando el Punto Final JWK Set
Comencemos creando un bean JWKSet usando la instancia KeyPair que configuramos anteriormente:
@Bean
public JWKSet jwkSet() {
    RSAKey.Builder builder = new RSAKey.Builder((RSAPublicKey) keyPair().getPublic())
      .keyUse(KeyUse.SIGNATURE)
      .algorithm(JWSAlgorithm.RS256)
      .keyID("bael-key-id");
    return new JWKSet(builder.build());
}
Ahora crear el punto final es bastante sencillo:
@RestController
public class JwkSetRestController {

    @Autowired
    private JWKSet jwkSet;

    @GetMapping("/.well-known/jwks.json")
    public Map<String, Object> keys() {
        return this.jwkSet.toJSONObject();
    }
}
El campo ID de clave que configuramos en la instancia JWKSet se traduce en el parámetro kid. Este kid es un alias arbitrario para la clave, y se usa comúnmente por el servidor de recursos para seleccionar la entrada correcta de la colección ya que la misma clave debe incluirse en la cabecera del JWT.
Nos enfrentamos a un nuevo problema ahora; dado que Spring Security OAuth no admite JWK, los JWT emitidos no incluirán la cabecera kid.
Agregando el Valor kid a la Cabecera del JWT
Crearemos una nueva clase que extienda el JwtAccessTokenConverter que hemos estado usando y que permite agregar entradas de cabecera a los JWTs:
public class JwtCustomHeadersAccessTokenConverter
  extends JwtAccessTokenConverter {

    // ...
}
Primero, necesitaremos:
  • Configurar la clase padre como hemos estado haciendo, configurando el KeyPair que hemos configurado.
  • Obtener un objeto Signer que use la clave privada del archivo de almacenamiento.
  • Por supuesto, una colección de cabeceras personalizadas que queremos agregar a la estructura.
Configurar la construcción basada en esto:
private Map<String, String> customHeaders = new HashMap<>();
final RsaSigner signer;

public JwtCustomHeadersAccessTokenConverter(
  Map<String, String> customHeaders,
  KeyPair keyPair) {
    super();
    super.setKeyPair(keyPair);
    this.signer = new RsaSigner((RSAPrivateKey) keyPair.getPrivate());
    this.customHeaders = customHeaders;
}
Ahora sobreescribiremos el método encode. Nuestra implementación será la misma que la de la clase padre, con la única diferencia de que también pasaremos las cabeceras personalizadas al crear el token String:
private JsonParser objectMapper = JsonParserFactory.create();

@Override
protected String encode(OAuth2AccessToken accessToken,
  OAuth2Authentication authentication) {
    String content;
    try {
        content = this.objectMapper
          .formatMap(getAccessTokenConverter()
          .convertAccessToken(accessToken, authentication));
    } catch (Exception ex) {
        throw new IllegalStateException(
          "Cannot convert access token to JSON", ex);
    }
    String token = JwtHelper.encode(
      content,
      this.signer,
      this.customHeaders).getEncoded();
    return token;
}
Utilizaremos esta clase ahora al crear el bean JwtAccessTokenConverter:
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
    Map<String, String> customHeaders =
      Collections.singletonMap("kid", "bael-key-id");
    return new JwtCustomHeadersAccessTokenConverter(
      customHeaders,
      keyPair());
}
Estamos listos para continuar. Recuerde cambiar de nuevo las propiedades del servidor de recursos. Necesitamos usar solo la propiedad key-set-uri que establecimos al principio del tutorial.
Podemos solicitar un Token de Acceso, verificar su valor kid, y usarlo para solicitar un recurso.
Conclusiones
Hemos aprendido bastante en esta guía completa sobre JWT, JWS y JWK. No solo configuraciones específicas de Spring, sino también conceptos generales de seguridad, viéndolos en acción con un ejemplo práctico.
Hemos visto la configuración básica de un servidor de recursos que maneja JWTs utilizando un punto final JWK Set.
Por último, hemos extendido las características básicas de Spring Security OAuth, configurando un servidor de autorización que expone un punto final JWK Set de manera eficiente.
Estos conceptos son sumamente importantes para programadores en JAVA que buscan implementar una arquitectura de seguridad moderna en sus aplicaciones. Si sigues estos pasos, estarás en camino de construir sistemas seguros y robustos utilizando Spring Security y la potencia de JWT.