Cómo refrescar el access token de un cliente OAuth cuando caduca

Escrito por el .
java planeta-codigo
Enlace permanente Comentarios

Dado que el protocolo HTTP es un protocolo sin estado los access token se envían en cada petición que se hace al endpoint de un recurso ofrecido por una aplicación mediante una interfaz REST. Para limitar el periodo de que un access tokens sea utilizable en caso de ser filtrado estos se crean con un tiempo de vida corto, los clientes para seguir realizando peticiones han de solicitar al servidor de autorización un nuevo access token. La librería OkHttp permite realizar la renovación de forma transparente para el cliente y en el código donde se usa.

OAuth

En el protocolo OAuth la autorización se concede o no en base a un token que para el cliente es simplemente una cadena de letras sin ningún sentido.

Dado que los access token tienen un tiempo de expiración relativamente corto el protocolo OAuth requiere que sean renovados cuando caducan para que los clientes sigan pudiendo realizar peticiones.

Los access token y refresh token de protocolo OAuth

El acceso a un servicio depende de tener un token a través de alguna de las formas que proporciona el protocolo OAuth, la principal y recomendada el que denomina authorization code grant que permite mantener privado tanto el secreto del cliente como el access token del agente del usuario.

Dado que los access token se envían en cada petición que realiza el cliente al servidor hay numerosas oportunidades que los access tokens sean capturados, para minimizar el problema de seguridad se generan access tokens con un tiempo de vida corto, de unos pocos minutos o unas pocas horas. Sin embargo, los clientes han de tener una forma de obtener un nuevo access token cuando este expira para seguir haciendo invocaciones en el servidor, al mismo tiempo que se genera un access token al cliente se le proporciona un refresh token que le permite obtener un nuevo access token cuando por ejemplo caduca.

Cuando un access token ya no es válido el servidor de recurso devuelve un código de estado que el cliente puede capturar para saber si el access token ha caducado y hay que obtener uno nuevo.

El obtener un access token implica hacer una petición al servidor de autorización proporcionando entre otros datos el refresh token. Para que el cliente no sea consciente de la renovación del access token y que estos pueden caducar en cualquier momento algunas librerías como OkHttp permiten a los clientes implementar el obtener un nuevo access token de forma transparente en las peticiones que se hagan.

Authenticator de OkHttp

La clase Authenticator permite realizar la autenticación antes de hacer la petición al servidor o cuando el servidor devuelva un determinado código de estado. Con una implementación de esta clase el código no necesita ser consciente de la autenticación que se necesita realizar en las peticiones al servidor.

Esta interfaz incluye un único método que hay que implementar, en el caso de que el método sea invocado y el código de respuesta de la petición haya sido un 401 y la petición incluya una cabecera de autorización quiere decir que las credenciales proporcionadas no son válidas en el caso de usar OAuth que el access token proporcionado no es válido, un caso es que el access token haya caducado, caso en el que hay que realizar la renovación del access token.

Implementar un autenticator para OkHttp

Cliente de servicio OAuth

Esta es la implementación de una interfaz Autheticator que permite renovar el access token cuando este caduca.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package io.github.picodotdev.blogbitix.springbootjaxrsoauth.client;

import java.io.IOException;

import okhttp3.Authenticator;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.Route;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

public class DefaultAuthenticator implements Authenticator {

    private AccessTokenRepository accessTokenRepository;

    public DefaultAuthenticator(AccessTokenRepository accessTokenRepository) {
        this.accessTokenRepository = accessTokenRepository;
    }

    @Nullable
    @Override
    public Request authenticate(@Nullable Route route, @NotNull Response response) throws IOException {
        if (!tryAuthenticate(response)) {
            return null;
        }

        synchronized (this) {
            if (accessTokenRepository.getAccessToken() == null) {
                accessTokenRepository.requestAccessToken();
            } else {
                accessTokenRepository.refreshAccessToken();
            }
        }

        return response.request().newBuilder()
                .header("Authorization", "Bearer " + accessTokenRepository.getAccessToken())
                .build();
    }

    private boolean tryAuthenticate(Response response) {
        return response.request().header("Authorization") == null || response.priorResponse() == null;
    }
}
client/DefaultAuthenticator.java

Esta otra clase de utilidad se encarga de almacenar el access token y refresh token y de realizar las peticiones para obtener el access token tanto la primera vez como cuando se solicita utilizando el refresh token. Utiliza el método client credentials para obtener un access token al mismo tiempo junto a un refresh token a partir de las credenciales del cliente y para hacer la renovación del access token se utiliza un refresh_token además de las credenciales del cliente.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package io.github.picodotdev.blogbitix.springbootjaxrsoauth.client;

import java.io.IOException;
import javax.json.Json;
import javax.json.JsonObject;

import okhttp3.FormBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;

public class AccessTokenRepository {

    private String tokenEndpoint;
    private OkHttpClient client;
    private String clientId;
    private String clientSecret;

    private String accessToken;
    private String refreshToken;

    public AccessTokenRepository(String tokenEndpoint, OkHttpClient client, String clientId, String clientSecret) {
        this.tokenEndpoint = tokenEndpoint;
        this.client = client;
        this.clientId = clientId;
        this.clientSecret = clientSecret;
    }

    public String getAccessToken() {
        return accessToken;
    }

    public void requestAccessToken() throws IOException {
        System.out.println("Requesting access token");

        RequestBody data = new FormBody.Builder()
                .add("grant_type", "client_credentials")
                .add("client_id", clientId)
                .add("client_secret", clientSecret)
                .build();
        Request tokenRequest = new Request.Builder().url(tokenEndpoint).post(data).build();
        Response tokenResponse = client.newCall(tokenRequest).execute();

        try (tokenResponse) {
            JsonObject tokenObject = Json.createReader(tokenResponse.body().charStream()).readObject();
            accessToken = tokenObject.getString("access_token");
            refreshToken = tokenObject.getString("refresh_token", null);
            Integer expiresIn = tokenObject.getInt("expires_in");
            Integer refreshExpires = tokenObject.getInt("refresh_expires_in", -1);

            System.out.printf("Access token: %s%n", accessToken);
            System.out.printf("Expires in: %d%n", expiresIn);
            System.out.printf("Refresh expires in: %d%n", refreshExpires);
            System.out.printf("Refresh token: %s%n", refreshToken);
        }
    }

    public void refreshAccessToken() throws IOException {
        System.out.println("Refreshing access token");

        RequestBody data = new FormBody.Builder()
                .add("grant_type", "refresh_token")
                .add("client_id", clientId)
                .add("client_secret", clientSecret)
                .add("refresh_token", refreshToken)
                .build();
        Request tokenRequest = new Request.Builder().url(tokenEndpoint).post(data).build();
        Response tokenResponse = client.newCall(tokenRequest).execute();
        try (tokenResponse) {
            JsonObject tokenObject = Json.createReader(tokenResponse.body().charStream()).readObject();
            accessToken = tokenObject.getString("access_token");
            refreshToken = tokenObject.getString("refresh_token", null);
            Integer expiresIn = tokenObject.getInt("expires_in");
            Integer refreshExpires = tokenObject.getInt("refresh_expires_in", -1);

            System.out.printf("Access token: %s%n", accessToken);
            System.out.printf("Expires in: %d%n", expiresIn);
            System.out.printf("Refresh expires in: %d%n", refreshExpires);
            System.out.printf("Refresh token: %s%n", refreshToken);
        }
    }
}
client/AccessTokenRepository.java

En el ejemplo el siguiente cliente que utiliza el método client credentials para obtener un access token hace peticiones cada unos pocos segundos al servidor del recurso. La renovación de access token se realiza de forma transparente y aunque el access token haya caducado y una petición falle con un código de estado 401 se invoca la renovación de access token y la petición con el access token renovado se reintenta. La interfaz Authenticator se usa al construir la instancia de OkHttp que utiliza el cliente del recurso.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package io.github.picodotdev.blogbitix.springbootjaxrsoauth.client;

import javax.json.Json;
import javax.json.JsonObject;

import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class Main {

    public static void main(String[] args) throws Exception {
        OkHttpClient baseClient = new OkHttpClient.Builder()
                .build();

        //curl -i http://localhost:9080/realms/my-realm/.well-known/openid-configuration
        System.out.println("Getting configuration...");
        Request configurationRequest = new Request.Builder().url("http://localhost:9080/realms/my-realm/.well-known/openid-configuration").get().build();
        Response configurationResponse = baseClient.newCall(configurationRequest).execute();

        JsonObject configurationObject = Json.createReader(configurationResponse.body().charStream()).readObject();
        String tokenEndpoint = configurationObject.getString("token_endpoint");

        AccessTokenRepository accessTokenRepository = new AccessTokenRepository(tokenEndpoint, baseClient, "spring-boot-client", "Bg1r6mOYsFraDw7u8VCgmGl4JtK8vShX");

        OkHttpClient client = baseClient.newBuilder()
                .authenticator(new DefaultAuthenticator(accessTokenRepository))
                .build();

        System.out.println("Getting an access token...");
        accessTokenRepository.requestAccessToken();

        //curl -ik -H "Authorization: Bearer aaaaa.bbbbb.ccccc" http://localhost:9080/message?string=Hola
        while(true) {
            System.out.println("Calling OAuth secured service...");
            Request serviceRequest = new Request.Builder()
                    .url(HttpUrl.parse("http://localhost:8080/message").newBuilder().addQueryParameter("string", "Hola").build())
                    .header("Authorization", "Bearer " + accessTokenRepository.getAccessToken())
                    .get()
                    .build();
            Response serviceResponse = client.newCall(serviceRequest).execute();

            try (serviceResponse) {
                JsonObject serviceObject = Json.createReader(serviceResponse.body().charStream()).readObject();
                System.out.printf("Result: %s%n", serviceObject.toString());
            }

            Thread.sleep(5000);
        }
    }
}
client/Main.java

El programa del cliente OAuth realiza peticiones al servidor del recurso utilizando el access token cuando este caduca la clase Authenticator es invocada y solicita al AccessTokenRepository un nuevo access token para ello utiliza el refresh token. Para el código que realiza la petición la caducidad del access token y la renovación ocurre sin su conocimiento.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
Calling OAuth secured service...
Result: {"message":"Hola","date":"2022-09-16T16:17:16.759+00:00"}
Calling OAuth secured service...
Result: {"message":"Hola","date":"2022-09-16T16:17:21.770+00:00"}
Calling OAuth secured service...
Result: {"message":"Hola","date":"2022-09-16T16:17:26.781+00:00"}
Calling OAuth secured service...
Refreshing access token
Access token: eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJQY1ZFRTN0TEl2bGhhVDFpVE16aWV2ZVN5SnJTMXlZTERNY3YzMTUwd0FrIn0.eyJleHAiOjE2NjMzNDUxMTEsImlhdCI6MTY2MzM0NTA1MSwianRpIjoiZjkyNTEwNWUtYjczYy00NDgxLWFkMTYtYzk3YzlkZWEwOTk0IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDgwL3JlYWxtcy9teS1yZWFsbSIsImF1ZCI6InNwcmluZy1ib290LWNsaWVudCIsInN1YiI6IjYzOTI1NWJlLTU0NGUtNGQ2Zi1iNDhjLWE2NjQ1YmY2OGYyYyIsInR5cCI6IkJlYXJlciIsImF6cCI6InNwcmluZy1ib290LWNsaWVudCIsInNlc3Npb25fc3RhdGUiOiJkZjEyMWQ0My0xMGUzLTQ0MzUtOWM2OC0wN2U1NjIyODg3YmUiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm1lc3NhZ2UtcXVlcnkiXX0sInJlc291cmNlX2FjY2VzcyI6eyJzcHJpbmctYm9vdC1jbGllbnQiOnsicm9sZXMiOlsibWVzc2FnZS1xdWVyeS0yIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwic2lkIjoiZGYxMjFkNDMtMTBlMy00NDM1LTljNjgtMDdlNTYyMjg4N2JlIiwiY2xpZW50SWQiOiJzcHJpbmctYm9vdC1jbGllbnQiLCJjbGllbnRIb3N0IjoiMTcyLjIzLjAuMSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoic2VydmljZS1hY2NvdW50LXNwcmluZy1ib290LWNsaWVudCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMjMuMC4xIn0.lE1YZDa1SCZdN7EUFGlHSs6L0wfoShoGq6WBlSm0we-sKW8wiCH0Kv4YBI9o9iioFFLPWmY7IW82nF-2Sp7PeVcqkEkRN5BSJ7J1ulIZN1F_L8t8A71yJPT_tG3q2oGLPeh0ooiVX7epW9jbePcEmOWYLYD9JM_xRNYLyThhHvS_R_LUNreW_rb0o_TjhAe3G55TvGccZV4XI2wYTbTU4a34rcU0vjdl5VP_cIogymTTgvr7OWkYxoU1nNkR1KZWCoYCdA95bQw4ut0j1cAupPesPUSA4hDCDvkicazn7cPWKel8qJ7qfR5R66V79nsvN0yIvG9XbjsjdzzZ-felvw
Expires in: 60
Refresh expires in: 1800
Refresh token: eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJiNzk4MGU2NC02ZjdhLTQxNTctYmFmNi1lN2FmYmM0OThmNzEifQ.eyJleHAiOjE2NjMzNDY4NTEsImlhdCI6MTY2MzM0NTA1MSwianRpIjoiY2QyYzU5M2QtMGUwMS00MzEwLWEyMzYtODZkNmViYWJhMzJmIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDgwL3JlYWxtcy9teS1yZWFsbSIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTA4MC9yZWFsbXMvbXktcmVhbG0iLCJzdWIiOiI2MzkyNTViZS01NDRlLTRkNmYtYjQ4Yy1hNjY0NWJmNjhmMmMiLCJ0eXAiOiJSZWZyZXNoIiwiYXpwIjoic3ByaW5nLWJvb3QtY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6ImRmMTIxZDQzLTEwZTMtNDQzNS05YzY4LTA3ZTU2MjI4ODdiZSIsInNjb3BlIjoiZW1haWwgcHJvZmlsZSIsInNpZCI6ImRmMTIxZDQzLTEwZTMtNDQzNS05YzY4LTA3ZTU2MjI4ODdiZSJ9.c-EYHLyT_0UQ7_1xl45C-ws8rTHxURxzm97src8AMAE
Result: {"message":"Hola","date":"2022-09-16T16:17:31.799+00:00"}
Calling OAuth secured service...
Result: {"message":"Hola","date":"2022-09-16T16:17:36.808+00:00"}
Calling OAuth secured service...
Result: {"message":"Hola","date":"2022-09-16T16:17:41.818+00:00"}
client/System.out

Servidor de recurso

El servidor de recursos implementado como una aplicación del Spring Boot comprueba el access token como parte de su autorización, en caso de que el access token sea inválido porque haya caducado o sea incorrecto se devuelve un código de estado 401. Spring Security proporciona las utilidades para validar el access token enviado en cada petición y configura Spring para todas las peticiones al servidor requieran un access token simplemente añadiendo la anotación @EnableWebSecurity y un poco de configuración en la aplicación.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package io.github.picodotdev.blogbitix.springbootjaxrsoauth.server;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/message")
public class MessageResource {

    @Autowired
    private MessageService messageService;

    @GetMapping(produces = "application/json")
    public Message message(@RequestParam("string") String string) {
        return messageService.create(string);
    }

}
server/MessageResource.java

Definiendo el bean del tipo JwtDecoder peremite personalizar las validaciones que se realizan sobre el access token por defecto Spring solo valida el issuer y en este ejemplo se muestra como validar otro campo o claim del documento JSON del token en formato JWT en este caso el de audiencia, si el claim iss indica quien ha emitido el aceess token el claim aud indica quien es el destinatario del access token.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package io.github.picodotdev.blogbitix.springbootjaxrsoauth.server;

...

@SpringBootApplication
@EnableWebSecurity
public class Main {

	@Bean
	MessageService messageService() {
		return new DefaultMessageService();
	}

	@Bean
	JwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuerUri) {
		NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuerUri);

		OAuth2TokenValidator<Jwt> issuerValidator = JwtValidators.createDefaultWithIssuer(issuerUri);
		OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(AUD, aud -> aud.contains("spring-boot-client"));
		OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(issuerValidator, audienceValidator);

		jwtDecoder.setJwtValidator(validator);

		return jwtDecoder;
	}

	public static void main(String[] args) {
		SpringApplication.run(Main.class, args);
	}
}
server/Main.java
1
2
3
4
5
6
7
8
9
server:
  port: 8080

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:9080/realms/my-realm
server/application.yml

Servidor de autorización

Como servidor de autorización se utiliza Keycloak creando un realm y un cliente que hay que crear en el realm que al hacerlo se obtienen sus credenciales, en este caso el servidor de Keycloak se inicia como un contenedor de Docker.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
version: '3.7'

services:
  keycloak:
    image: quay.io/keycloak/keycloak
    environment:
      - KEYCLOAK_ADMIN=admin
      - KEYCLOAK_ADMIN_PASSWORD=admin
    ports:
      - "9080:8080"
    command: start-dev
docker/docker-compose.yml

Clientes en Keycloak

Clientes en Keycloak
Terminal

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando siguiente comando:
./gradlew client:run


Comparte el artículo: