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

El cliente que lleva tu sesión

Al terminar: Poner el plugin Auth de Ktor, el interceptor del 403 y sus pruebas.

El repositorio ya sabe dar un token válido y renovarlo una sola vez. Falta lo que lo usa: un cliente de red que ponga el token en cada petición y que, cuando el servidor diga «este token ya no vale», pida uno nuevo y repita la petición sin que quien llamó se entere. Esta lección lo construye con el plugin de sesión de Ktor, la librería de red que ya usas para PokeAPI.

Hay una trampa que justifica media lección: Ktor solo reacciona ante un 401 («no autorizado»), pero el servidor de autenticación de Supabase contesta 403 cuando el token caducó. Sin tratarlo, el refresco automático no se dispara justo en las llamadas que importan aquí. El paso 3 lo arregla con un interceptor, una pieza que mira cada respuesta antes de que llegue a quien preguntó.

Al terminar tendrás un tercer cliente de red en la app, conectado al repositorio, y seis tests que lo prueban con un servidor falso. Siguen sin existir pantallas de sesión.

Paso 1 · Instala el plugin de sesión de Ktor

gradle
libs.versions.tomluna línea en [libraries]
shared
build.gradle.ktsuna dependencia en commonMain
Lo que se toca en este paso.

Qué instalas y por qué ahora. ktor-client-auth es el plugin de Ktor que pone la cabecera Authorization en cada petición y, cuando el servidor dice «no autorizado», pide tokens nuevos y repite la petición. Es el trabajo de esta lección, así que entra con ella. Es la receta de HTTP y Ktor: el cliente de PokeAPI, sin tocar la sección [plugins]: solo una librería. (No confundas dos «plugins»: los de Gradle cambian cómo se compila el proyecto; el de Ktor es una pieza que se instala dentro de un cliente de red.)

1 · En [libraries]. Abre gradle/libs.versions.toml y añade una línea al final de la sección [libraries], junto a las otras de Ktor. No hace falta tocar [versions]: reutiliza la ktor que ya tienes, para que todas las piezas de Ktor lleven siempre la misma versión.

gradle/libs.versions.toml: [libraries], línea nueva
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }

2 · En el módulo que la usa. Abre shared/build.gradle.kts y, dentro de commonMain.dependencies, junto a las demás de Ktor:

shared/build.gradle.kts: línea nueva en commonMain.dependencies
implementation(libs.ktor.client.auth)  // implementation: solo la usa este módulo, las apps no la ven

3 · Sync Now, y comprueba. Pulsa Sync Now; si sale en rojo, repasa la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto. El error típico es escribir libs.ktor-client-auth con guion en lugar de punto. Para comprobar que llegó:

./gradlew :shared:dependencies --configuration jvmCompileClasspath | grep ktor-client-auth

El | pasa la salida del primer comando al segundo, y grep ktor-client-auth se queda solo con las líneas que contienen ese texto. Debe aparecer io.ktor:ktor-client-auth: seguido de la misma versión que el resto de tus librerías de Ktor.

Paso 2 · El cliente que lleva la sesión

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network
SupabaseAuthApi.ktse modifica: un hueco en createSupabaseClient
SupabaseAccountApi.ktse crea: lo que sí lleva sesión
Un tercer cliente de red, con su propia API.

El archivo. SupabaseAccountApi.kt es el cliente para todo lo que se pide en nombre del usuario (hoy, solo leer su perfil). Es el tercero de la app, y son tres a propósito:

Por qué el que refresca no lleva el plugin. El plugin llama al repositorio para obtener tokens, y el repositorio llama al cliente de refresco. Si ese cliente llevara también el plugin y el servidor contestara «no autorizado» al refresco, el plugin pediría otro refresco, que esperaría al primero, y se quedarían esperándose para siempre. En una línea:

cliente con sesión → pide tokens al repositorio → el repositorio refresca con el cliente SIN sesión
(si el de refresco también llevara plugin, volvería al principio: un círculo sin salida)

Es lo que se llama un bloqueo mutuo: dos personas en una puerta estrecha, cada una esperando a que la otra pase primero. Nadie se mueve nunca.

Esto no hace falta que lo escribas, porque el montaje es largo. Quien lo probó (con KtorSupabaseAuthApi montada sobre el cliente con sesión y un servidor falso que siempre contesta 401) vio que el test no termina: espera un minuto entero y acaba con UncompletedCoroutinesError: After waiting for 1m, the test body did not run to completion. Es la prueba de que el círculo existe.

Antes, un hueco en el cliente sin sesión. El cliente nuevo va a reutilizar createSupabaseClient (la apikey, los tiempos de espera, el JSON) y solo le añadirá el plugin. Para eso la función tiene que dejar un hueco por el que pasarle configuración extra.

El hueco es el parámetro extra, de tipo HttpClientConfig<*>.() -> Unit. Se lee así: «un bloque que se ejecuta sobre la configuración de un cliente». Es el mismo tipo de bloque que escribes dentro de HttpClient { … }, y por eso dentro funciona install(...). El <*> dice que da igual con qué motor de red se use. Abre shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAuthApi.kt y sustituye la función createSupabaseClient entera por esta (las dos líneas con extra son lo nuevo):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAuthApi.kt: createSupabaseClient, con el parámetro nuevo
internal fun createSupabaseClient(
    config: AppConfig,
    engine: HttpClientEngine? = null,
    extra: HttpClientConfig<*>.() -> Unit = {},   // NUEVO: configuración que añada quien llama
): HttpClient {
    val configuracion: HttpClientConfig<*>.() -> Unit = {
        install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
        install(HttpTimeout) {
            requestTimeoutMillis = 15_000
            connectTimeoutMillis = 10_000
        }
        defaultRequest {
            url(config.supabaseUrl.trimEnd('/') + "/auth/v1/")
            header("apikey", config.supabaseKey)
        }
        extra()   // NUEVO: aquí entra el plugin de sesión (el cliente de abajo)
    }
 
    return if (engine == null) HttpClient(configuracion) else HttpClient(engine, configuracion)
}

Como extra vale {} por defecto (un bloque vacío), quien ya llama a createSupabaseClient(config) no cambia.

Créalo. Clic derecho sobre la carpeta shared → New → File y escribe src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAccountApi.kt (el método de El módulo compartido y la prueba de humo). Debajo de la línea package com.sebastiannarvaez.pokedex.data.network, los imports:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAccountApi.kt: imports
import com.sebastiannarvaez.pokedex.core.AppConfig
import com.sebastiannarvaez.pokedex.data.network.dto.UserDto      // el DTO del usuario
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.auth.Auth                           // el plugin que acabas de instalar
import io.ktor.client.plugins.auth.providers.BearerTokens         // la pareja (acceso, refresco)
import io.ktor.client.plugins.auth.providers.bearer               // el proveedor «portador»
import io.ktor.client.request.get

Primero la API, que es lo único que verá el resto del código, y su implementación:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAccountApi.kt: la API
/** Lo que se le pide a Supabase en nombre del usuario. */
internal interface SupabaseAccountApi {
    suspend fun perfil(): UserDto
}
 
internal class KtorSupabaseAccountApi(private val client: HttpClient) : SupabaseAccountApi {
    // "user" es relativo a la dirección base del cliente (…/auth/v1/).
    // La cabecera Authorization la pone el plugin; aquí no se ve.
    override suspend fun perfil(): UserDto = client.get("user").body()
}

Ahora, de dónde saca el cliente los tokens. No se le pasa el repositorio entero, sino una interfaz de dos funciones: así el cliente depende de lo que necesita y no de quién se lo da. Cómo encaja eso en el grafo de Koin, sin que el cliente y el repositorio se esperen el uno al otro, lo explica el paso 4.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAccountApi.kt: de dónde salen los tokens
internal interface ProveedorDeTokens {
    /** El par (acceso, refresco) vigente; refresca antes solo si lo pide el reloj. */
    suspend fun vigentes(): Pair<String, String>?
 
    /** Refresca sí o sí. Lo llama el cliente cuando el servidor ya ha dicho que no. */
    suspend fun refrescar(): Pair<String, String>?
}

Y la función que construye el cliente. Reutiliza createSupabaseClient y le añade el plugin en su parámetro extra. Fíjate en que loadTokens y refreshTokens terminan igual, con ?.let { … }: «si hay par de tokens, conviértelo en un BearerTokens; si no (null), devuelve null».

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAccountApi.kt: el cliente
internal fun createSupabaseAccountClient(
    config: AppConfig,
    proveedor: ProveedorDeTokens,
    engine: HttpClientEngine? = null,   // null en producción; un MockEngine en los tests
): HttpClient {
    val extra: HttpClientConfig<*>.() -> Unit = {
        install(Auth) {                  // instala el plugin en el cliente
            bearer {                     // y le dice que use el esquema «portador»
                // Ktor llama a loadTokens una vez y guarda el resultado. El
                // refresco por reloj lo hace el repositorio: aquí basta
                // pedirle el par vigente.
                loadTokens {
                    proveedor.vigentes()?.let { (acceso, refresco) -> BearerTokens(acceso, refresco) }
                }
                // Ktor llama a refreshTokens cuando el servidor contesta 401
                // (el 403 de Supabase lo trata el interceptor del paso 3).
                // Hay que FORZAR el refresco: preguntar otra vez al reloj
                // devolvería el mismo token que el servidor acaba de rechazar.
                refreshTokens {
                    proveedor.refrescar()?.let { (acceso, refresco) -> BearerTokens(acceso, refresco) }
                }
                // Decide a qué peticiones se les pone la cabecera ya al salir.
                // Por defecto Ktor la pone en todas las de este cliente; el
                // filtro la limita al host de Supabase, para que el token no
                // salga hacia PokeAPI ni hacia ningún otro sitio.
                // (Se lee «enviar sin que te lo pidan»: con `true`, la cabecera va
                // en la primera petición, sin esperar a que el servidor la reclame.)
                sendWithoutRequest { peticion ->
                    peticion.url.host == config.supabaseUrl.substringAfter("://").trimEnd('/')
                }
            }
        }
    }
    return createSupabaseClient(config, engine, extra)
}

Los tres bloques, uno a uno: loadTokens es «dame los tokens con los que empezar», se llama una vez y Ktor se los guarda; refreshTokens es «el servidor dijo que no: dame otros»; y sendWithoutRequest (en la práctica, «¿esta petición lleva ya la cabecera?», true o false) filtra por dirección: config.supabaseUrl.substringAfter("://") se queda con lo que va detrás de https:// (el host, por ejemplo abcdefgh.supabase.co) y solo las peticiones a ese host llevan la cabecera.

Paso 3 · Supabase contesta 403 donde Ktor espera 401

Ktor solo llama a refreshTokens ante un 401. Pero el servidor de autenticación de Supabase (/auth/v1/…) contesta 403 con bad_jwt cuando el token ya no sirve, y el de datos (/rest/v1/…, la parte de Supabase que guarda y sirve las tablas de tu base de datos) contesta 401. Es decir: el plugin del paso 2 refresca en las llamadas a /rest/v1 y no en las de /auth/v1, que son justo las de esta lección (leer el perfil, cerrar sesión).

Provócalo tú. Abre la Terminal de Android Studio. Las variables de shell se pierden al cerrar la Terminal o abrir otra pestaña, así que defínelas de nuevo (con tus valores de Preparar Supabase) y entra:

SUPABASE_URL=https://abcdefgh.supabase.co
SUPABASE_KEY=sb_publishable_xxxxxxxx
 
curl -s -X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" \
  -H "apikey: $SUPABASE_KEY" -H "Content-Type: application/json" \
  -d '{"email":"tu.nombre+pokedex@gmail.com","password":"contrasena-larga"}'

Copia el access_token de la respuesta, espera más de 60 segundos (la caducidad que pusiste) y úsalo. La opción -i imprime la cabecera de la respuesta, y su primera línea lleva el código de estado:

ACCESS_TOKEN=pega-aqui-el-access_token
curl -i "$SUPABASE_URL/auth/v1/user" \
  -H "apikey: $SUPABASE_KEY" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

La primera línea dice HTTP/2 403, y el cuerpo, bad_jwt. No se nota mientras el repositorio refresque por reloj antes de pedir nada; pero la red de seguridad existe precisamente para cuando el reloj se equivoca.

La solución es un interceptor que trate el 403 de /auth/v1/ como si fuera un 401: refresca de forma forzada y repite la petición. Es un paso obligatorio de esta lección. Ktor llama HttpSend a la pieza por la que pasa cada petición justo antes de salir, y intercept es el bloque que se ejecuta con cada una.

Va en SupabaseAccountApi.kt. Primero, estos import junto a los demás:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAccountApi.kt: imports del interceptor
import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.auth.authProvider
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
import io.ktor.client.plugins.plugin
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode

Y, en createSupabaseAccountClient, sustituye la última línea (return createSupabaseClient(config, engine, extra)) por este bloque:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAccountApi.kt: el final de createSupabaseAccountClient, con el interceptor del 403
    val cliente = createSupabaseClient(config, engine, extra)
 
    // Se ejecuta en cada petición de este cliente, alrededor del envío.
    cliente.plugin(HttpSend).intercept { peticion ->
        val llamada = execute(peticion)                                     // la petición normal
        if (llamada.response.status != HttpStatusCode.Forbidden) return@intercept llamada
        // Solo en /auth/v1/: en /rest/v1/ un 403 significa que una regla de
        // seguridad de filas dijo que no, y refrescar ahí gastaría un token
        // por cada negativa legítima.
        // `buildString()` aquí es una función de la dirección (`peticion.url`): la
        // devuelve entera como texto, para poder mirar si contiene «/auth/v1/».
        if (!peticion.url.buildString().contains("/auth/v1/")) return@intercept llamada
 
        val nuevos = proveedor.refrescar() ?: return@intercept llamada      // refresco forzado
 
        // Ktor guarda los tokens que devolvió loadTokens; sin olvidarlos, las
        // siguientes peticiones seguirían firmando con el rechazado.
        // `authProvider<BearerAuthProvider>()` busca, entre las formas de
        // autenticación instaladas, la de tipo «portador» (el `<…>` dice qué tipo
        // buscas); el `?.` es por si no hubiera ninguna.
        cliente.authProvider<BearerAuthProvider>()?.clearToken()
 
        // Los corchetes con `=` ponen (o sustituyen) la cabecera de ese nombre,
        // como en un diccionario: ahora «Authorization» lleva el token nuevo.
        peticion.headers[HttpHeaders.Authorization] = "Bearer ${nuevos.first}"
        execute(peticion)                                                   // la repetición, una sola
    }
 
    return cliente

Línea a línea, lo que hace ese bloque:

  1. Manda la petición y mira cómo vuelve. Si no es un 403, la deja pasar tal cual (return@intercept sale del bloque, como el return@withContext de la lección anterior).
  2. Si es un 403 pero la ruta no es /auth/v1/, también la deja pasar. En /rest/v1 un 403 significa otra cosa: que una regla de seguridad de filas dijo que no. Ese no se refresca, y por eso el interceptor mira la ruta.
  3. Pide el refresco forzado. Si no hay sesión (null), se rinde y devuelve el 403 original.
  4. Le dice a Ktor que olvide el token que guardó (clearToken), y pone la cabecera nueva a mano en la petición.
  5. La repite una sola vez. Si la repetición también da 403, el resultado se devuelve tal cual, para no entrar en un bucle.

Con él, una petición a /auth/v1/user que recibe un 403 refresca una vez y se repite con el token nuevo, y la siguiente ya sale con el token nuevo. Los tests del paso 5 lo comprueban.

Paso 4 · Conectarlo en Koin, sin círculos

Hay un ciclo en el grafo de Koin: el cliente con sesión necesita tokens, los tokens los da SessionRepository, y SessionRepository está en el mismo grafo, que también tiene que construir el cliente. Se rompe con dos cosas juntas:

  • el cliente solo conoce la interfaz ProveedorDeTokens, no al repositorio;
  • el get<SessionRepository>() se escribe dentro de las funciones de un objeto anónimo que la implementa. Esas funciones no se ejecutan al montar el grafo, sino cuando Ktor las llama al hacer una petición; para entonces el repositorio ya existe. Es la diferencia entre ir a comprar pan ahora (un get() suelto, que se ejecuta al construir) y dejar una nota que diga «cuando llegues a casa, compra pan» (un get() dentro de una función, que se ejecuta cada vez que alguien la llama).

Abre shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt (Koin: un solo grafo en commonMain). Añade estos cuatro imports a los que ya hay:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt: imports nuevos
import com.sebastiannarvaez.pokedex.data.network.KtorSupabaseAccountApi
import com.sebastiannarvaez.pokedex.data.network.ProveedorDeTokens
import com.sebastiannarvaez.pokedex.data.network.SupabaseAccountApi
import com.sebastiannarvaez.pokedex.data.network.createSupabaseAccountClient

El nombre del cliente nuevo, encima del comentario de val pokedexModule, justo debajo de val SUPABASE (el import org.koin.core.qualifier.named ya lo tienes de la lección anterior). El orden importa por lo mismo que antes: una val declarada más abajo todavía no se ha inicializado cuando se construye el módulo, y se lee como null aunque su tipo diga que nunca puede serlo (Kotlin no lo detecta al compilar).

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt: el nombre del cliente nuevo, encima de pokedexModule
// El cliente de Supabase que sí lleva la sesión del usuario.
val SUPABASE_CUENTA = named("supabase-cuenta")

Y, dentro de pokedexModule, debajo de la línea single { SessionRepository(get(), get(), get()) }:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt: el cliente con sesión, dentro de pokedexModule
    // El `get()` de dentro se resuelve al hacer la petición, no al montar el
    // grafo: es lo que rompe el ciclo cliente -> repositorio -> cliente.
    single(SUPABASE_CUENTA) {
        createSupabaseAccountClient(
            get(),                                 // la configuración
            object : ProveedorDeTokens {           // dos funciones que miran al repositorio
                override suspend fun vigentes() = get<SessionRepository>().tokensVigentes()
                override suspend fun refrescar() = get<SessionRepository>().refrescarAhora()
            },
        )
    }
    single<SupabaseAccountApi> { KtorSupabaseAccountApi(get(SUPABASE_CUENTA)) }

Comprueba el orden de SUPABASE_CUENTA (cuenta en las cifras)

Esto no es opcional: el test nuevo es el séptimo de la lección y entra en el recuento del final.

  1. Abre el OrdenTest.kt que creaste en La sesión en el núcleo, en shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/di/OrdenTest.kt, y añade este import junto a los demás:
shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/di/OrdenTest.kt: import nuevo
import com.sebastiannarvaez.pokedex.data.network.SupabaseAccountApi
  1. Añade este test dentro de la clase, debajo del que ya tiene:
shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/di/OrdenTest.kt: un test nuevo, dentro de la clase
    @Test
    fun laApiDeCuentaSeConstruye() {
        val config = object : BaseAppConfig() {
            override val supabaseUrl = "https://proyecto.supabase.co"
            override val supabaseKey = "clave-publica"
        }
        val koin = startKoin {
            modules(pokedexModule, platformModule, module { single<AppConfig> { config } })
        }.koin
        try {
            koin.get<SupabaseAccountApi>()
            assertNotSame(koin.get<HttpClient>(), koin.get<HttpClient>(SUPABASE_CUENTA))
        } finally {
            stopKoin()
        }
    }
  1. Lánzalo: ./gradlew :shared:jvmTest --tests '*OrdenTest*' (el * vale por cualquier texto, así que coge la clase OrdenTest entera). Pasa.
  2. Rómpelo: mueve val SUPABASE_CUENTA = named("supabase-cuenta") (con su comentario) al final de Modules.kt y vuelve a lanzar el mismo comando. Falla con InstanceCreationException: Could not create instance for … SupabaseAccountApi, y ModulesTest sigue en verde.
  3. Devuélvela a su sitio y comprueba que vuelve a pasar.

Paso 5 · Los tests, que aquí son la única forma de verlo

Un «no autorizado» real no se puede provocar cuando uno quiere. Con MockEngine, el servidor de mentira que ya usas, sí. Hay dos piezas: un fichero que se copia (cuatro tests) y dos tests que escribes tú, los del 403.

1 · Los tests del cliente, con el plugin de por medio. Antes de copiarlo, su esquema, para que no te encuentres nombres sin explicar. Es un extracto para leer, no para escribir:

esquema de SupabaseAccountApiTest.kt (no lo escribas: lo trae el fichero)
// La respuesta falsa de «leer el perfil», y la cabecera «Content-Type: application/json».
private val PERFIL = """{"id":"u-1","email":"ash@pueblo-paleta.test"}"""
private val JSON = headersOf(…)
 
class SupabaseAccountApiTest {
    // Un repositorio de verdad (con su candado) pero con dobles: FakeAuthApi y el reloj congelado.
    private fun TestScope.sesion(api: FakeAuthApi) = SessionRepository(api, InMemoryTokenStore(), …)
 
    // Un ProveedorDeTokens que mira a ese repositorio, como el de Koin del paso 4.
    private fun proveedor(repo: SessionRepository) = object : ProveedorDeTokens { … }
 
    @Test fun `…`() = runTest { … }   // cuatro tests con esta misma forma
}

Cada test hace lo mismo: monta un MockEngine (el servidor falso) que contesta lo que toque, entra con un repositorio, hace una petición con el cliente de cuenta y comprueba lo que salió. Es el patrón de los que ya conoces (un MockEngine, un doble y un assertEquals) y no aporta nada nuevo, así que el fichero se copia con Copiar fichero (el botón de Del JSON al Pokémon: DTO y repositorio): clic derecho sobre la carpeta shared → New → File → src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAccountApiTest.kt, pulsa el botón, selecciona todo (Cmd+A) y pega.

SupabaseAccountApiTest.kt

El fichero se escribió antes de que supieras dos cosas que acabas de aprender (cómo filtra sendWithoutRequest y qué perdona Supabase con el token de refresco), y dos de sus comentarios quedaron viejos. Un comentario que miente es peor que ninguno, así que corrígelos tú. Busca el primero, dentro del primer test:

así viene: el comentario del primer test
        // Una sola peticion: si `sendWithoutRequest` no filtrara por host,
        // Ktor mandaria una sin cabecera, recibiria 401 y repetiria.
así debe quedar
        // Una sola peticion, y ya con la cabecera: el token sale en la
        // primera, sin esperar a que el servidor diga 401.

Por qué: Ktor manda la cabecera en todas las peticiones por defecto; el filtro sendWithoutRequest solo evita que salga hacia otros dominios (lo verás en «Tres cosas que se pueden romper» más abajo). Y el segundo, el comentario de encima del test tres 401 a la vez refrescan una sola vez:

así viene: el comentario del test de las tres peticiones
    /**
     * Dos peticiones caducadas a la vez: el token de refresco es de un solo
     * uso, asi que solo puede haber un refresco. Aqui se comprueba de punta a
     * punta, con el plugin de por medio y no solo el repositorio.
     */
así debe quedar
    /**
     * Tres peticiones rechazadas a la vez: tiene que haber un solo refresco.
     * Aqui se comprueba de punta a punta, con el plugin de por medio y no solo
     * el repositorio. (Con este doble instantaneo pasaria tambien sin el
     * candado: el que lo prueba de verdad es el de las cinco peticiones de
     * SessionRepositoryTest.)
     */

Por qué: Supabase deja reusar un token de refresco durante 10 segundos (Refrescar el token sin duplicados, paso 4), así que no es «de un solo uso». Y ese test usa async para lanzar las tres a la vez, pero con un doble que contesta al instante no hay carrera.

Mira el caso que justifica el plugin: el reloj dice que el token vale, pero el servidor lo rechaza con 401. Es el segundo test del fichero, entero:

SupabaseAccountApiTest.kt: un 401 refresca y repite
    @Test
    fun `un 401 refresca y repite la peticion con el token nuevo`() = runTest {
        var primera = true
        val motor = MockEngine {                      // el servidor falso
            if (primera) {
                primera = false
                respond("""{"message":"invalid JWT"}""", HttpStatusCode.Unauthorized, JSON)  // la 1.ª: «no»
            } else {
                respond(PERFIL, HttpStatusCode.OK, JSON)                                    // la 2.ª: bien
            }
        }
        val api = FakeAuthApi()
        val repo = sesion(api)                       // un repositorio con una sesión recién entrada
        repo.entrar("ash@pueblo-paleta.test", "pikachu")
 
        val perfil = KtorSupabaseAccountApi(
            createSupabaseAccountClient(ConfigDePrueba(), proveedor(repo), motor),
        ).perfil()
 
        assertEquals("ash@pueblo-paleta.test", perfil.email)
        assertEquals(1, api.refrescos)               // un solo refresco
        assertEquals(2, motor.requestHistory.size)   // dos peticiones: la rechazada y la repetida
        assertEquals("Bearer access-entrada", motor.requestHistory[0].headers[HttpHeaders.Authorization])
        assertEquals("Bearer access-refresco-1", motor.requestHistory[1].headers[HttpHeaders.Authorization])
        // Y la sesión guardada también queda actualizada, no solo la del cliente.
        assertEquals("access-refresco-1", repo.state.value.session?.accessToken)
    }

2 · Los dos tests del 403. Supabase contesta 403, no 401, así que el caso real es el del interceptor del paso 3. El fichero copiado no lo cubre: escribe tú estos dos tests dentro de la clase, debajo del de arriba (los imports del fichero ya sirven). El primero es el mismo caso con el 403 real de Supabase; el segundo comprueba que la petición siguiente ya sale con el token nuevo:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAccountApiTest.kt: los dos tests del 403
    @Test
    fun `un 403 de auth v1 refresca y repite la peticion con el token nuevo`() = runTest {
        var primera = true
        val motor = MockEngine {
            if (primera) {
                primera = false
                respond("""{"code":403,"error_code":"bad_jwt","msg":"invalid JWT"}""", HttpStatusCode.Forbidden, JSON)
            } else {
                respond(PERFIL, HttpStatusCode.OK, JSON)
            }
        }
        val api = FakeAuthApi()
        val repo = sesion(api)
        repo.entrar("ash@pueblo-paleta.test", "pikachu")
 
        val perfil = KtorSupabaseAccountApi(
            createSupabaseAccountClient(ConfigDePrueba(), proveedor(repo), motor),
        ).perfil()
 
        assertEquals("ash@pueblo-paleta.test", perfil.email)
        assertEquals(1, api.refrescos)
        assertEquals(2, motor.requestHistory.size)
        assertEquals("Bearer access-entrada", motor.requestHistory[0].headers[HttpHeaders.Authorization])
        assertEquals("Bearer access-refresco-1", motor.requestHistory[1].headers[HttpHeaders.Authorization])
    }
 
    @Test
    fun `tras un 403 la siguiente peticion ya sale con el token nuevo`() = runTest {
        var primera = true
        val motor = MockEngine {
            if (primera) {
                primera = false
                respond("""{"error_code":"bad_jwt"}""", HttpStatusCode.Forbidden, JSON)
            } else {
                respond(PERFIL, HttpStatusCode.OK, JSON)
            }
        }
        val api = FakeAuthApi()
        val repo = sesion(api)
        repo.entrar("ash@pueblo-paleta.test", "pikachu")
        val cuenta = KtorSupabaseAccountApi(
            createSupabaseAccountClient(ConfigDePrueba(), proveedor(repo), motor),
        )
 
        cuenta.perfil()
        cuenta.perfil()
 
        assertEquals(1, api.refrescos)
        assertEquals("Bearer access-refresco-1", motor.requestHistory.last().headers[HttpHeaders.Authorization])
    }

Lanza ./gradlew :shared:jvmTest: los seis de este fichero pasan (cuatro copiados y dos tuyos).

Tres cosas que se pueden romper (si quieres verlas)

Cada una, una vez, y vuelve a dejar el código como estaba:

  • Sin el interceptor. Quita el bloque cliente.plugin(HttpSend).intercept { … } y lanza los tests. Fallan los dos del 403 con JsonConvertException: Field 'id' is required … UserDto: sin el interceptor, Ktor intenta leer la respuesta de error como si fuera un usuario.
  • Sin clearToken(). Quita esa línea. Falla solo tras un 403 la siguiente peticion ya sale con el token nuevo, con expected:<Bearer access-[refresco-1]> but was:<Bearer access-[entrada]>: Ktor seguía firmando con el token que guardó al principio, el que el servidor acababa de rechazar.
  • Sin el filtro del host. Dentro de createSupabaseAccountClient, borra el bloque sendWithoutRequest { … }. Falla uno, el token no sale hacia otro dominio, con actual value is not null: sin el filtro, Ktor manda la cabecera Authorization a cualquier dirección, también a PokeAPI, que no tiene por qué ver el token de nadie.

Paso 6 · Comprueba los tests

Esta lección añade siete tests: los seis de SupabaseAccountApiTest y el segundo de OrdenTest (que solo corre en la JVM). Lanza las tres tareas sin filtrar 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 105. Repite el open con testAndroidHostTest (67) y iosSimulatorArm64Test (70). Si pasas --tests a Gradle, el informe cuenta solo esas clases y la cifra será menor.

Punto de control

  • gradle/libs.versions.toml tiene ktor-client-auth y shared/build.gradle.kts tiene implementation(libs.ktor.client.auth); Sync Now terminó sin rojo.
  • Existe SupabaseAccountApi.kt con la API, ProveedorDeTokens, el cliente con el plugin y el interceptor del 403.
  • Los informes enseñan 105 (JVM), 67 (Android host) y 70 (iOS): seis más por SupabaseAccountApiTest, y uno más en la JVM por el segundo test de OrdenTest.
  • Una petición a /auth/v1/user que recibe un 403 refresca una vez y se repite con el token nuevo.
  • Una petición a otro dominio a través del cliente de cuenta sale sin cabecera Authorization.
  • En Koin, los dos clientes de Supabase están registrados con nombres distintos, SUPABASE y SUPABASE_CUENTA están encima de pokedexModule, OrdenTest pasa y el verify() de ModulesTest sigue en verde.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Por qué el cliente que refresca no lleva instalado el plugin de sesión?

  2. 2.¿Para qué sirve sendWithoutRequest filtrando por host?

  3. 3.El interceptor que refresca ante un 403 se limita a las rutas /auth/v1/. ¿Por qué?

  4. 4.Con el plugin Auth de Ktor instalado, ¿por qué hace falta además un interceptor para las llamadas a /auth/v1/?

  5. 5.El interceptor llama a clearToken() antes de repetir la petición. ¿Qué pasaría sin esa línea?

  6. 6.Si la petición repetida por el interceptor también recibe un 403, ¿qué ocurre?

Faltan 6 de 6