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.
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:
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.
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:
/**
* `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:
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.delayY al final del fichero, debajo de TestDispatchers, la clase nueva:
/**
* 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:
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlin.test.assertFailsWithasync { … } 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.
@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:
if (dentro.refreshToken != gastado) return@withLock dentroFalla 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.htmlTermina 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.ktestán elcandado,tokenValido,tokensVigentes,refrescarAhorayrefrescar, y no hay nada en rojo. FakeAuthApivive ahora enDobles.kt, conretrasoDelRefresco, ySessionRepositoryTest.ktya no tiene su propia copia.SessionRepositoryTesttiene 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.¿Por qué hace falta una forma de refrescar «forzada», distinta del refresco por reloj?
2.Con el
Mutexpuesto pero sin la comprobación de dentro, ¿qué pasa con cinco peticiones caducadas a la vez?3.Un test lanza cinco peticiones con
asynccontra un doble que contesta al instante. ¿Por qué sigue pasando aunque quites el candado?4.Tras refrescar, ¿por qué hay que guardar también el token de refresco nuevo y no solo el de acceso?
5.Tienes «JWT expiry» en 60 segundos y el margen de
caducaAntesDetambién es de 60. ¿Qué verás en la app?6.Supabase deja usar un token de refresco más de una vez durante 10 segundos. Entonces, ¿para qué sirve el candado?