Saltar al contenido
K
Autenticación con SupabasePaso a paso28 min Experto

Refrescar el token sin duplicados

Al terminar: Renovar la sesión con el plugin Auth de Ktor y sobrevivir a dos peticiones concurrentes.

Con «JWT expiry» en 60 segundos (el ajuste que pusiste en Preparar Supabase) ya sabes lo que pasa: entras, esperas un minuto y el token con el que entraste deja de servir. Hasta ahora la app no hace nada al respecto: SessionRepository sabe entrar, salir y guardar la sesión, pero no renueva nada. Esta lección le enseña a hacerlo, y a hacerlo una sola vez aunque se lo pidan cinco peticiones a la vez.

Suena a poner un if antes de cada petición, y no lo es, por dos motivos. El primero: a veces el servidor rechaza un token que tu reloj considera válido, y entonces mirar la hora no sirve de nada. El segundo: si dos peticiones se dan cuenta de que el token caducó al mismo tiempo, las dos van a la red a renovarlo (cinco peticiones, cinco viajes donde bastaba uno) y la última en llegar puede dejar guardada una sesión que ya nadie usa. Supabase perdona un segundo uso del token de refresco durante unos segundos (lo verás en el paso 4), pero eso no quita el trabajo de más ni la carrera al guardar.

Al terminar tendrás en el repositorio tres funciones nuevas (dar un token válido, dar el par de tokens para el cliente de red y renovar a la fuerza) y un candado que garantiza un solo refresco a la vez. No hay pantallas ni red real: todo se comprueba con tests y un servidor de mentira. Conectar esto con Ktor, la librería de red, es la lección siguiente, El cliente que lleva tu sesión.

shared/src
commonMain/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepository.ktse modifica (pasos 2 y 3)
commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/Dobles.ktse modifica (paso 5): llega FakeAuthApi
commonTest/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepositoryTest.ktse modifica (pasos 5 y 6)
Tres ficheros que ya existen y se modifican: uno de la app y dos de los tests.

Paso 1 · Dos formas de renovar

El repositorio necesita dos formas de renovar el token, y la diferencia importa:

  • Por reloj es el camino normal: antes de salir una petición se mira si el token está a punto de caducar. Casi nunca hay que ir a la red.
  • Forzado es cuando el servidor ya ha dicho que no. Mirar la hora no sirve: el servidor ha hablado. Y las razones sobran (el reloj del móvil va adelantado, la sesión se cerró desde otro dispositivo, un administrador la revocó) y ninguna se ve desde la app.

Para decidir «a punto de caducar» el repositorio usa caducaAntesDe de Session (la escribiste en Autenticación en el núcleo: la API), que cuenta con un margen de 60 segundos: un token al que le quedan menos de 60 segundos ya no se considera bueno, porque no sobreviviría a una petición lenta.

Paso 2 · Las tres funciones del repositorio

El archivo. SessionRepository.kt ya existe (lo escribiste en la lección anterior). Ábrelo: shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepository.kt. Añade dos imports a los que ya hay y una línea debajo de val state:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepository.kt: imports nuevos
import kotlinx.coroutines.sync.Mutex      // el candado de la Recuerda de arriba
import kotlinx.coroutines.sync.withLock   // «entra, haz esto y sal»: el candado se suelta solo

```kotlin title="shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepository.kt: el candado, debajo de val state" // Un solo refresco a la vez en toda la app (paso 3). private val candado = Mutex()


Y debajo de `salir`, estas tres funciones. Cada una usa `refrescar`, una función **privada del repositorio** que se escribe en el paso siguiente (hasta entonces el editor la marcará en rojo). No la confundas con `SupabaseAuthApi.refrescar`, que es la llamada de red, ni con la que tendrá el cliente de red en la lección siguiente:

```kotlin title="shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepository.kt: tres funciones nuevas"
    /**
     * Un token válido, refrescando solo si está a punto de caducar.
     * Es el camino normal: se mira el reloj y casi nunca se va a la red.
     */
    suspend fun tokenValido(): String? = withContext(dispatchers.io) {
        val actual = _state.value.session ?: return@withContext null   // sin sesión, no hay token
        // Lo que vale la última línea del bloque es lo que devuelve la función:
        // un `if … else` también da un valor, el de la rama que se ejecuta.
        if (!actual.caducaAntesDe(ahoraEnSegundos())) actual.accessToken   // aún vale
        else refrescar(actual.refreshToken)?.accessToken                   // casi caducado: renovar
    }

    /** El par que necesita el cliente de red: el plugin de Ktor (lo instalarás en la lección siguiente) guarda los dos tokens. */
    suspend fun tokensVigentes(): Pair<String, String>? {
        // Se llama solo por su efecto: si hacía falta renovar, se renueva (y la
        // sesión de `_state` queda al día). El valor no se usa; si es `null`,
        // no hay sesión y se sale. Después se vuelve a leer `_state`, que ya
        // lleva los tokens nuevos.
        tokenValido() ?: return null
        val sesion = _state.value.session ?: return null
        return sesion.accessToken to sesion.refreshToken
    }

    /**
     * Refresca aunque el reloj diga que aún vale.
     * Lo llama el cliente cuando el servidor ya ha respondido «no autorizado».
     */
    suspend fun refrescarAhora(): Pair<String, String>? = withContext(dispatchers.io) {
        val actual = _state.value.session ?: return@withContext null
        val nueva = refrescar(actual.refreshToken) ?: return@withContext null
        nueva.accessToken to nueva.refreshToken
    }

Lo que hace cada una, en una frase: tokenValido devuelve el token de acceso (null si no hay sesión) y renueva solo si toca; tokensVigentes devuelve el par (acceso, refresco) porque el cliente de red necesita los dos; refrescarAhora renueva sin mirar el reloj. El ?: de tokensVigentes significa «si lo de la izquierda es null, haz esto otro»: aquí, salir de la función con null.

Paso 3 · El candado, y la comprobación de dentro

Antes del código real, el candado en pequeño. Es un ejemplo para leer, no para escribir: una taquilla con una sola entrada en venta y varios clientes a la vez.

ejemplo comentado: un Mutex y la comprobación de dentro (no lo escribas)
val taquilla = Mutex()
var entradasLibres = 1
 
suspend fun comprar(): Boolean = taquilla.withLock {   // entra uno; los demás esperan suspendidos
    if (entradasLibres == 0) return@withLock false      // la comprobación de DENTRO: ¿sigue habiendo?
    entradasLibres--
    true
}
// Cinco clientes a la vez: de uno en uno por la ventanilla, y solo el primero compra.
// Sin el `if` de dentro, los cinco comprarían (y entradasLibres acabaría en -4).

Los dos caminos del repositorio acaban en la misma función privada, con la misma forma. Va debajo de refrescarAhora:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepository.kt: refrescar, con candado
    /**
     * `gastado` es el token de refresco con el que venía quien llama.
     */
    private suspend fun refrescar(gastado: String): Session? = candado.withLock {
        val dentro = _state.value.session ?: return@withLock null
        // Si dentro del candado el token vigente ya no es el que traíamos,
        // alguien refrescó mientras esperábamos: no hay nada que hacer.
        if (dentro.refreshToken != gastado) return@withLock dentro
 
        // Un `try` también da un valor: si todo va bien, es lo que devuelve
        // `publicar` (la sesión nueva), y eso es lo que devuelve `refrescar`.
        try {
            publicar(api.refrescar(gastado))      // la petición de verdad
        } catch (e: Throwable) {
            // Si el refresco falla, la sesión está muerta: dejarla puesta haría
            // que cada petición fallara sin explicación.
            store.borrar()
            _state.value = AuthState(comprobando = false, session = null)
            throw e   // volver a lanzar el MISMO error: quien llamó se entera de que falló
        }
    }

Las dos mitades hacen falta, y es fácil quedarse en la primera. El Mutex por sí solo pone los refrescos en fila: entra uno, sale, entra el siguiente. Pero el siguiente seguiría refrescando, con un token que el primero ya gastó. La comprobación de dentro («el token con el que venías ya no es el vigente») es la que convierte cinco refrescos en uno: quien llega segundo se encuentra el trabajo hecho y devuelve la sesión nueva sin ir a la red.

Los dos tokens. Cada refresco devuelve otro token de refresco, y publicar guarda la sesión entera, los dos. Si solo guardaras el de acceso, el siguiente refresco volvería a gastar el token de refresco viejo; y si cae fuera de la ventana de 10 segundos que verás en el paso 4, Supabase daría la sesión por robada y la cerraría.

El catch. Si el refresco falla (el token de refresco ya no vale, no hay red de verdad…), la sesión guardada no sirve para nada. Se borra del almacén y de la memoria, y se vuelve a lanzar el error para que lo vea quien llamó. El usuario acabará en la pantalla de entrada, que es lo honesto: seguir con una sesión muerta solo produce errores sin explicación.

Paso 4 · Lo que dice la documentación de Supabase

Todo lo anterior se comprueba con un doble, y un doble hace lo que tú le dices. Queda un hecho de Supabase de verdad que cambia el motivo del candado.

El token de refresco admite un segundo uso. Podrías pensar que gastarlo dos veces tumba la sesión. La documentación de Supabase lo matiza:

El candado sigue haciendo falta, pero por otra razón: evita cinco viajes de red para el mismo resultado y una carrera al escribir el estado, que es la que puede dejar guardada la sesión perdedora. Y la rama del catch, que borra la sesión cuando el refresco falla, es la que te protege si un refresco cae fuera de la ventana de 10 segundos.

Paso 5 · Un servidor de mentira que tarda

Los tests de SessionRepositoryTest usan un servidor de mentira, FakeAuthApi, que ahora necesita más cosas (contar refrescos, fallar a propósito, tardar) y lo van a usar más ficheros. Por eso sale de SessionRepositoryTest.kt, donde es una clase privada, y pasa a Dobles.kt, donde ya tienes los demás dobles.

Abre shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/Dobles.kt. Añade estos cuatro imports a los que ya hay:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/Dobles.kt: imports nuevos
import com.sebastiannarvaez.pokedex.data.network.SupabaseAuthApi
import com.sebastiannarvaez.pokedex.data.network.dto.SessionDto
import com.sebastiannarvaez.pokedex.data.network.dto.UserDto
import kotlinx.coroutines.delay

Y al final del fichero, debajo de TestDispatchers, la clase nueva:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/Dobles.kt: FakeAuthApi
/**
 * El doble del servidor de autenticación.
 *
 * `expiresInTrasRefresco` no es un capricho: si el refresco devolviera la misma
 * duración corta, con el reloj congelado del test el token nuevo también
 * nacería caducado y el repositorio refrescaría en bucle. Un servidor de
 * verdad nunca devuelve un token ya vencido.
 *
 * `retrasoDelRefresco` hace que el refresco tarde, como uno de verdad.
 */
internal class FakeAuthApi(
    private val expiresIn: Long = 3600,                // lo que dura el token al entrar
    private val expiresInTrasRefresco: Long = 3600,    // y el que llega tras refrescar
    private val fallaElRefresco: Boolean = false,      // para probar el refresco que sale mal
    private val retrasoDelRefresco: Long = 0,          // milisegundos que tarda en contestar
) : SupabaseAuthApi {
 
    var refrescos = 0                  // cuántas veces le han pedido refrescar
        private set
    var cierresEnElServidor = 0        // y cuántas cerrar sesión
        private set
 
    override suspend fun registrar(email: String, password: String) = sesion("registro", expiresIn)
    override suspend fun entrar(email: String, password: String) = sesion("entrada", expiresIn)
 
    override suspend fun refrescar(refreshToken: String): SessionDto {
        delay(retrasoDelRefresco)   // mientras tanto, las demás peticiones llegan al candado
        refrescos++
        // `error(texto)` lanza una `IllegalStateException` con ese texto.
        if (fallaElRefresco) error("refresh token ya usado")
        return sesion("refresco-$refrescos", expiresInTrasRefresco)   // refresco-1, refresco-2…
    }
 
    override suspend fun salir(accessToken: String) {
        cierresEnElServidor++
    }
 
    private fun sesion(marca: String, expiresIn: Long) = SessionDto(
        accessToken = "access-$marca",
        refreshToken = "refresh-$marca",
        expiresIn = expiresIn,
        user = UserDto(id = "u-1", email = "ash@pueblo-paleta.test"),
    )
}

Después, en shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepositoryTest.kt, borra la clase privada FakeAuthApi entera (con su comentario) y los imports de SessionDto y UserDto, y añade import com.sebastiannarvaez.pokedex.dobles.FakeAuthApi junto a los demás. Los cinco tests de la lección anterior se quedan tal cual.

Paso 6 · Los tests del refresco

Cinco tests nuevos, dentro de la clase SessionRepositoryTest, debajo del último que tiene. Primero los imports que necesitan, junto a los demás del fichero:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepositoryTest.kt: imports nuevos
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlin.test.assertFailsWith

async { … } lanza una corrutina que devuelve un resultado, y awaitAll() espera a todas las de una lista y recoge los resultados juntos: lo viste en Del JSON al Pokémon: DTO y repositorio. Aquí sirve para lanzar cinco peticiones a la vez.

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepositoryTest.kt: los cinco tests del refresco
    @Test
    fun `un token vigente no se refresca`() = runTest {
        val api = FakeAuthApi()                      // caduca en 3600 s: sobra
        val r = repo(api)
        r.entrar("ash@pueblo-paleta.test", "pikachu")
 
        assertEquals("access-entrada", r.tokenValido())
        assertEquals(0, api.refrescos)               // no se fue a la red
    }
 
    @Test
    fun `un token a punto de caducar se refresca`() = runTest {
        val api = FakeAuthApi(expiresIn = 30)        // caduca en 30 s: dentro del margen de 60
        val r = repo(api)
        r.entrar("ash@pueblo-paleta.test", "pikachu")
 
        assertEquals("access-refresco-1", r.tokenValido())
        assertEquals(1, api.refrescos)
    }
 
    @Test
    fun `cinco peticiones con un servidor lento refrescan una sola vez`() = runTest {
        val api = FakeAuthApi(expiresIn = 30, retrasoDelRefresco = 100)
        val r = repo(api)
        r.entrar("ash@pueblo-paleta.test", "pikachu")
 
        // Cinco corrutinas lanzadas ANTES de esperar a ninguna: de verdad a la vez.
        val tokens = List(5) { async { r.tokenValido() } }.awaitAll()
 
        assertEquals(1, api.refrescos)
        assertTrue(tokens.all { it == "access-refresco-1" })   // las cinco reciben el token nuevo
    }
 
    @Test
    fun `si el refresco falla la sesion se cierra`() = runTest {
        val store = InMemoryTokenStore()
        val r = repo(FakeAuthApi(expiresIn = 30, fallaElRefresco = true), store)
        r.entrar("ash@pueblo-paleta.test", "pikachu")
 
        assertFailsWith<IllegalStateException> { r.tokenValido() }
 
        assertNull(r.state.value.session)            // fuera de la memoria…
        assertNull(store.leer())                     // …y del almacén
    }
 
    @Test
    fun `sin sesion no hay token que dar`() = runTest {
        assertNull(repo().tokenValido())             // `repo()` sin entrar: no hay sesión
    }

Dos detalles del test de las cinco peticiones: List(5) { … } construye una lista de cinco elementos ejecutando el bloque cinco veces (aquí, cinco async), y tokens.all { it == "…" } es true solo si todos los elementos cumplen la condición (it es cada elemento).

El ayudante repo ya tiene el reloj congelado en 1 000 por defecto (ahora = { 1_000 }), así que un token que caduca «en 30 segundos» siempre cuenta como a punto de caducar, y uno de 3600 siempre como vigente.

Provócalo tú: quita la comprobación de dentro

Borra esta línea de la función refrescar del repositorio y lanza ./gradlew :shared:jvmTest:

la línea que vas a borrar
        if (dentro.refreshToken != gastado) return@withLock dentro

Falla solo el test de las cinco peticiones, con:

AssertionError: expected:<1> but was:<5>

Los demás siguen en verde. Es lo que demuestra el test: el Mutex solo no basta. Con el candado y sin la comprobación, las cinco entran de una en una, ordenadas, y cada una vuelve a ir a la red: cinco viajes donde bastaba uno. Es un fallo que a mano casi nunca verías, porque hace falta que varias peticiones caducadas salgan en el mismo instante; el test lo fabrica a propósito.

Prueba también a quitar el candado entero (cambia candado.withLock { por run { y los return@withLock por return@run): el mismo test falla igual. Hacen falta las dos cosas. Vuelve a dejar el código como estaba.

Paso 7 · Comprueba los tests

Esta lección añade cinco tests (los de arriba). Lanza las tres tareas sin filtrar, para que corran las tres plataformas, y abre los informes:

./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test
open shared/build/reports/tests/jvmTest/index.html

Termina en BUILD SUCCESSFUL y la página enseña arriba el recuadro «tests» con 98. Repite el open con testAndroidHostTest (61) y iosSimulatorArm64Test (64). Ojo: si pasas --tests a Gradle para correr solo una clase, el informe cuenta solo esas y la cifra será menor.

Punto de control

  • En SessionRepository.kt están el candado, tokenValido, tokensVigentes, refrescarAhora y refrescar, y no hay nada en rojo.
  • FakeAuthApi vive ahora en Dobles.kt, con retrasoDelRefresco, y SessionRepositoryTest.kt ya no tiene su propia copia.
  • SessionRepositoryTest tiene diez tests y todos pasan.
  • Los informes enseñan 98 (JVM), 61 (Android host) y 64 (iOS), cinco más que al terminar la lección anterior.
  • Quitar la comprobación de dentro hace fallar solo el test de las cinco peticiones, con expected:<1> but was:<5>.
  • La app compila y arranca igual que antes: todavía no hay red ni pantallas de sesión que usen esto.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Por qué hace falta una forma de refrescar «forzada», distinta del refresco por reloj?

  2. 2.Con el Mutex puesto pero sin la comprobación de dentro, ¿qué pasa con cinco peticiones caducadas a la vez?

  3. 3.Un test lanza cinco peticiones con async contra un doble que contesta al instante. ¿Por qué sigue pasando aunque quites el candado?

  4. 4.Tras refrescar, ¿por qué hay que guardar también el token de refresco nuevo y no solo el de acceso?

  5. 5.Tienes «JWT expiry» en 60 segundos y el margen de caducaAntesDe también es de 60. ¿Qué verás en la app?

  6. 6.Supabase deja usar un token de refresco más de una vez durante 10 segundos. Entonces, ¿para qué sirve el candado?

Faltan 6 de 6