Refresh automático de tokens con AWS Cognito en iOS

En el artículo anterior implementamos la persistencia de sesión, permitiendo que los usuarios no tengan que hacer login cada vez que abren la app. Sin embargo, hay un problema crítico: los tokens Access e ID expiran después de 60 minutos. Si el usuario tiene la app abierta por más de una hora, las llamadas a la API fallarán. En este artículo implementaremos el refresh automático de tokens para renovarlos antes de que expiren.

¿Qué vamos a construir?

En este artículo implementaremos:

  1. JWTDecoder – Decodificar tokens JWT para obtener la fecha de expiración
  2. Método refreshTokens – Llamar a Cognito para renovar Access/ID tokens usando el Refresh Token
  3. TokenRefreshManager – Verificar y refrescar tokens automáticamente cuando sea necesario
  4. Integración en ContentView – Verificar y refrescar tokens al abrir la app
  5. Manejo de errores – Si el Refresh Token expiró, forzar re-login

El problema actual

Ciclo de vida de los tokens:

Login exitoso
    ↓
Access Token → Válido por 60 minutos ⏱️
ID Token → Válido por 60 minutos ⏱️
Refresh Token → Válido por 30 días 📅
    ↓
Después de 60 minutos
    ↓
Access/ID tokens EXPIRAN ❌
    ↓
API calls fallan (401 Unauthorized)
    ↓
Usuario tiene que hacer login otra vez 😞

Lo que queremos:

Login exitoso
    ↓
Access Token → Válido por 60 minutos
    ↓
App detecta que están por expirar (ej: quedan 5 minutos)
    ↓
App usa Refresh Token automáticamente
    ↓
Cognito devuelve NUEVOS Access/ID tokens
    ↓
App guarda nuevos tokens en Keychain
    ↓
Usuario sigue usando la app sin interrupciones ✅
    ↓
Se repite cada 60 minutos (mientras Refresh Token sea válido – 30 días)

Requisitos previos

  • Haber completado el Artículo 5 (Persistencia de sesión)
  • Tener KeychainManager implementado
  • Tener CognitoAuthService con métodos signIn y signUp
  • Entender conceptos básicos de JWT (JSON Web Tokens)

¿Qué es un JWT?

Un JWT (JSON Web Token) tiene esta estructura:

eyJhbGc…header.eyJzdWI…payload.SflKxwRJ…signature

Se divide en 3 partes separadas por puntos: 1. Header – Información sobre el algoritmo de firma 2. Payload – Datos del token (incluye fecha de expiración “exp”) 3. Signature – Firma para verificar autenticidad

Los tokens de Cognito (Access e ID) son JWTs. El Refresh Token es opaco (no es JWT).

Ejemplo de payload decodificado:

{
  "sub": "12345678-1234-1234-1234-123456789abc",
  "email": "usuario@example.com",
  "exp": 1701234567,  // ← Fecha de expiración (Unix timestamp)
  "iat": 1701230967,  // Fecha de emisión
  ...
}

El campo exp nos dice cuándo expira el token (segundos desde 1970).

Paso 1: Crear JWTDecoder para decodificar tokens

Primero necesitamos poder decodificar los tokens para saber cuándo expiran.

Crear archivo JWTDecoder.swift:

import Foundation

struct JWTDecoder {

    // Decodificar JWT y obtener fecha de expiración

    /// Decodifica un JWT y devuelve la fecha de expiración
    static func getExpirationDate(from token: String) -> Date? {
        // Separar el token en sus 3 partes: header.payload.signature
        let segments = token.split(separator: ".")

        // Necesitamos el payload (segunda parte)
        guard segments.count == 3 else {
            print("❌ Token JWT inválido - no tiene 3 partes")
            return nil
        }

        let payloadSegment = String(segments[1])

        // Decodificar el payload de Base64
        guard let payloadData = base64UrlDecode(payloadSegment) else {
            print("❌ No se pudo decodificar el payload del JWT")
            return nil
        }

        // Parsear el JSON del payload
        guard let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
              let exp = json["exp"] as? TimeInterval else {
            print("❌ No se encontró el campo 'exp' en el payload")
            return nil
        }

        // Convertir el timestamp Unix a Date
        let expirationDate = Date(timeIntervalSince1970: exp)
        print("✅ Token expira en: \(expirationDate)")
        return expirationDate
    }

    /// Verifica si un token está expirado
    static func isTokenExpired(_ token: String) -> Bool {
        guard let expirationDate = getExpirationDate(from: token) else {
            return true  // Si no podemos decodificar, asumimos que está expirado
        }
        return Date() >= expirationDate
    }

    /// Verifica si un token expirará pronto (dentro de N minutos)
    static func willExpireSoon(_ token: String, minutesThreshold: Int = 5) -> Bool {
        guard let expirationDate = getExpirationDate(from: token) else {
            return true  // Si no podemos decodificar, asumimos que necesita refresh
        }

        // Calcular la fecha límite (ahora + threshold)
        let thresholdDate = Date().addingTimeInterval(TimeInterval(minutesThreshold * 60))

        // Si la expiración es antes del threshold, necesitamos refresh
        return expirationDate <= thresholdDate
    }

    /// Obtiene el tiempo restante hasta la expiración del token
    static func getTimeUntilExpiration(_ token: String) -> TimeInterval? {
        guard let expirationDate = getExpirationDate(from: token) else {
            return nil
        }

        let timeRemaining = expirationDate.timeIntervalSinceNow
        return max(0, timeRemaining) // No devolver valores negativos
    }

    // Base64 URL Decoding

    /// Decodifica una cadena Base64 URL-safe
    /// JWT usa Base64 URL-safe que es diferente a Base64 estándar
    private static func base64UrlDecode(_ value: String) -> Data? {
        var base64 = value
            .replacingOccurrences(of: "-", with: "+")
            .replacingOccurrences(of: "_", with: "/")

        // Agregar padding si es necesario
        let remainder = base64.count % 4
        if remainder > 0 {
            base64 += String(repeating: "=", count: 4 - remainder)
        }

        return Data(base64Encoded: base64)
    }
}

¿Qué hace este código?

  1. getExpirationDate():
    • Divide el JWT en 3 partes (header.payload.signature)
    • Decodifica el payload de Base64 URL-safeExtrae el campo exp (expiración)
    • Convierte el timestamp Unix a Date
  2. isTokenExpired():
    • Verifica si el token ya expiró comparando con Date()
    • Devuelve true si está expirado
  3. willExpireSoon():
    • Verifica si el token expira en menos de N minutos (default: 5)
    • Útil para refrescar ANTES de que expire
  4. getTimeUntilExpiration():
    • Calcula cuántos segundos faltan para la expiración
    • Útil para mostrar al usuario o para logging
  5. base64UrlDecode():
    • JWT usa Base64 URL-safe (diferente a Base64 estándar)
    • Reemplaza – con + y _ con /
    • Agrega padding = si es necesario

Paso 2: Agregar método refreshTokens en CognitoAuthService

Ahora agregamos la funcionalidad de refresh al servicio de autenticación.

Agregar en CognitoAuthService.swift (después del método signIn):

// Refresh de Tokens
/// Renueva los tokens Access e ID usando el Refresh Token
func refreshTokens(refreshToken: String) async throws -> AuthTokens {
    guard let client = cognitoClient else {
        throw NSError(domain: "CognitoAuthService", code: -1,
                     userInfo: [NSLocalizedDescriptionKey: "Cliente de Cognito no inicializado"])
    }

    // Crear los parámetros de autenticación con el Refresh Token
    let authParameters = [
        "REFRESH_TOKEN": refreshToken
    ]

    // Crear la solicitud de refresh usando el flow REFRESH_TOKEN_AUTH
    let initiateAuthInput = InitiateAuthInput(
        authFlow: .refreshTokenAuth,
        authParameters: authParameters,
        clientId: clientId
    )

    do {
        let response = try await client.initiateAuth(input: initiateAuthInput)

        print("✅ Tokens refrescados exitosamente")

        // Extraer los nuevos tokens de la respuesta
        guard let authResult = response.authenticationResult else {
            throw NSError(domain: "CognitoAuthService", code: 5001,
                         userInfo: [NSLocalizedDescriptionKey: "No se pudieron obtener los nuevos tokens"])
        }

        // IMPORTANTE: El refresh NO devuelve un nuevo Refresh Token
        // Debemos mantener el Refresh Token original
        let tokens = AuthTokens(
            accessToken: authResult.accessToken ?? "",
            idToken: authResult.idToken ?? "",
            refreshToken: refreshToken  // ← Mantenemos el mismo Refresh Token
        )

        print("Nuevos tokens obtenidos:")
        print("Access Token: \(tokens.accessToken.prefix(20))...")
        print("ID Token: \(tokens.idToken.prefix(20))...")
        print("Refresh Token: (sin cambios)")

        return tokens

    } catch {
        print("❌ Error al refrescar tokens: \(error)")

        let errorMessage = error.localizedDescription
        if errorMessage.contains("NotAuthorizedException") {
            throw NSError(domain: "CognitoAuthService", code: 5002,
                        userInfo: [NSLocalizedDescriptionKey: "Refresh Token inválido o expirado. Debes iniciar sesión nuevamente"])
        } else {
            throw NSError(domain: "CognitoAuthService", code: 5000,
                        userInfo: [NSLocalizedDescriptionKey: "Error al refrescar tokens: \(errorMessage)"])
        }
    }
}

¿Qué hace este método?

  1. AuthFlow diferente:
    • Usa .refreshTokenAuth en lugar de .userPasswordAuth
    • Solo necesita el Refresh Token (no username/password)
  2. Parámetros:
    • «REFRESH_TOKEN»: refreshToken
    • Solo enviamos el Refresh Token
  3. IMPORTANTE sobre el Refresh Token:
    • AWS Cognito NO devuelve un nuevo Refresh Token al refrescar
    • Solo devuelve nuevos Access e ID tokens
    • Debemos mantener el Refresh Token original
    • El Refresh Token solo se renueva en un login completo
  4. Manejo de errores:
    • NotAuthorizedException → Refresh Token expiró o es inválido (código 5002)
    • Otros errores → Error genérico (código 5000)
    • Si el Refresh Token expiró, el usuario DEBE hacer login otra vez

Paso 3: Crear TokenRefreshManager

Creamos un manager que coordina todo el proceso de verificación y refresh automático.

Crear archivo TokenRefreshManager.swift:

import Foundation

class TokenRefreshManager {

    static let shared = TokenRefreshManager()

    private let authService = CognitoAuthService()
    private let keychainManager = KeychainManager.shared

    // Threshold en minutos: si el token expira en menos de este tiempo, lo refrescamos
    private let expirationThresholdMinutes = 5

    private init() {}

    // Verificar y Refrescar Tokens

    /// Verifica si los tokens necesitan ser refrescados y los renueva automáticamente
    /// - Returns: true si los tokens son válidos (o fueron refrescados exitosamente), false si necesitan re-login
    @discardableResult
    func checkAndRefreshTokensIfNeeded() async -> Bool {
        print("🔍 Verificando estado de los tokens...")

        // 1. Leer tokens del Keychain
        guard let tokens = keychainManager.readTokens() else {
            print("⚠️ No hay tokens guardados")
            return false
        }

        // 2. Verificar si el Access Token necesita refresh
        let needsRefresh = JWTDecoder.willExpireSoon(
            tokens.accessToken,
            minutesThreshold: expirationThresholdMinutes
        )

        if !needsRefresh {
            // Los tokens aún son válidos
            if let timeRemaining = JWTDecoder.getTimeUntilExpiration(tokens.accessToken) {
                let minutes = Int(timeRemaining / 60)
                print("✅ Tokens válidos. Expiran en ~\(minutes) minutos")
            }
            return true
        }

        // 3. Los tokens necesitan refresh
        print("⏰ Tokens están por expirar. Iniciando refresh...")

        do {
            // 4. Llamar al servicio para refrescar
            let newTokens = try await authService.refreshTokens(refreshToken: tokens.refreshToken)

            // 5. Guardar los nuevos tokens en Keychain
            let saved = keychainManager.saveTokens(newTokens)

            if saved {
                print("✅ Tokens refrescados y guardados exitosamente")
                return true
            } else {
                print("❌ Error al guardar los nuevos tokens")
                return false
            }

        } catch {
            print("❌ Error al refrescar tokens: \(error.localizedDescription)")

            // Si el Refresh Token expiró, debemos forzar re-login
            if (error as NSError).code == 5002 {
                print("🔴 Refresh Token expirado. Usuario debe hacer login nuevamente")
                // Limpiar tokens inválidos
                _ = keychainManager.deleteTokens()
            }

            return false
        }
    }

    //  Métodos auxiliares

    func hasValidTokens() -> Bool {
        guard let tokens = keychainManager.readTokens() else {
            return false
        }
        return !JWTDecoder.isTokenExpired(tokens.accessToken)
    }

    func getTimeUntilTokenExpiration() -> TimeInterval? {
        guard let tokens = keychainManager.readTokens() else {
            return nil
        }
        return JWTDecoder.getTimeUntilExpiration(tokens.accessToken)
    }
}

¿Qué hace TokenRefreshManager?

  1. Singleton pattern:
    • static let shared = TokenRefreshManager()
    • Una sola instancia compartida en toda la app
  2. checkAndRefreshTokensIfNeeded() – El método principal:
    • Paso 1: Lee tokens de Keychain
    • Paso 2: Verifica si expiran pronto (< 5 minutos)
    • Paso 3: Si están OK → return true
    • Paso 4: Si expiran pronto → llama refreshTokens()
    • Paso 5: Guarda nuevos tokens en Keychain
    • Paso 6: Si refresh falla → limpia tokens y return false
  3. Threshold de 5 minutos:
    • No esperamos a que expiren completamente
    • Refrescamos cuando quedan menos de 5 minutos
    • Esto da margen para evitar errores de API
  4. Manejo de errores:
    • Si Refresh Token expiró (código 5002) → elimina tokens y fuerza re-login
    • El usuario verá SignInView automáticamente

Paso 4: Integrar refresh automático en ContentView

Finalmente, actualizamos ContentView para usar TokenRefreshManager en lugar de solo verificar si existen tokens.

Actualizar método checkSession() en ContentView.swift:

// Verificar Sesión
private func checkSession() {
    // Verificar y refrescar tokens si es necesario
    Task {
        // TokenRefreshManager verifica si los tokens existen, si son válidos,
        // y los renueva automáticamente si están por expirar
        let hasValidSession = await TokenRefreshManager.shared.checkAndRefreshTokensIfNeeded()

        await MainActor.run {
            isLoggedIn = hasValidSession
            isCheckingSession = false

            if hasValidSession {
                print("✅ Sesión válida - Navegando a HomeView")
            } else {
                print("⚠️ No hay sesión válida - Mostrando SignInView")
            }
        }
    }
}

¿Qué cambió?

Antes (Artículo 5):

let hasTokens = KeychainManager.shared.hasTokens()
// Solo verificaba si había tokens, no si eran válidos

Ahora (Artículo 6):

let hasValidSession = await TokenRefreshManager.shared.checkAndRefreshTokensIfNeeded()
// Verifica si hay tokens, si son válidos, y los renueva automáticamente

Flujo completo: 1. Usuario abre la app 2. ContentView.onAppear() ejecuta checkSession() 3. checkSession() llama a TokenRefreshManager 4. TokenRefreshManager verifica tokens: – ¿Hay tokens? NO → return false → SignInView – ¿Expiran pronto? NO → return true → HomeView – ¿Expiran pronto? SÍ → refresh automático → return true → HomeView – ¿Refresh falló? SÍ → return false → SignInView

Paso 5: Probar el flujo de refresh automático

Ahora vamos a probar que el refresh funcione correctamente:

Prueba 1: Sesión con tokens válidos (NO refresh)

  • 1. Hacer login
    • Ingresa email y contraseña
    • Presiona “Iniciar Sesión”
    • Console:
      • ✅ Tokens guardados en Keychain
  • 2. Cerrar y volver a abrir la app inmediatamente
    • Los tokens fueron creados hace < 5 minutos
    • Console:
      • 🔍 Verificando estado de los tokens…
      • ✅ Tokens válidos. Expiran en ~55 minutos
      • ✅ Sesión válida – Navegando a HomeView
      • ✅ NO hubo refresh porque los tokens aún son válidos

Prueba 2: Forzar refresh (simulado)

Para probar el refresh necesitamos tokens que estén por expirar. Hay dos formas:

Opción A: Esperar 55 minutos (no práctico)

Opción B: Modificar temporalmente el threshold (para testing)

Modifica TokenRefreshManager.swift temporalmente:

// TEMPORAL SOLO PARA TESTING
private let expirationThresholdMinutes = 60  // Era 5, ahora 60

Esto hará que SIEMPRE intente refrescar porque los tokens siempre estarán “por expirar” (< 60 min

  • 3. Rebuild y volver a abrir la app
    • Console:
      • 🔍 Verificando estado de los tokens…
      • ⏰ Tokens están por expirar. Iniciando refresh…
      • ✅ Tokens refrescados exitosamente
      • ✅ Tokens refrescados y guardados exitosamente
      • ✅ Sesión válida – Navegando a HomeView
      • ✅ Los tokens fueron refrescados automáticamente
      • ✅ El usuario NO tuvo que hacer login otra vez
  • 4 Restaurar el threshold:
 	private let expirationThresholdMinutes = 5  // Volver al valor original

Prueba 3: Refresh Token expirado (forzar re-login)

Para probar esto necesitamos un Refresh Token expirado. Esto ocurre después de 30 días o si cambias la configuración en Cognito.

Simular Refresh Token expirado:

  • 1. Modificar KeychainManager temporalmente (corromper el Refresh Token):
    • En signIn() de SignInView, después de guardar tokens:
      • // TEMPORAL – Corromper Refresh Token para simular expiración
        let corruptedTokens = AuthTokens(
            accessToken: tokens.accessToken,
            idToken: tokens.idToken,
            refreshToken: «token_expirado_invalido»
        )
        let saved = KeychainManager.shared.saveTokens(corruptedTokens)
  • 2. Hacer login y cerrar la app
  • 3. Volver a abrir la app
    • Console:
      • 🔍 Verificando estado de los tokens…
      • ⏰ Tokens están por expirar. Iniciando refresh…
      • ❌ Error al refrescar tokens: Refresh Token inválido o expirado
      • 🔴 Refresh Token expirado. Usuario debe hacer login nuevamente
      • ⚠️ No hay sesión válida – Mostrando SignInView
      • ✅ Los tokens fueron eliminados
      • ✅ El usuario ve SignInView (debe hacer login otra vez)
  • 4. Eliminar el código temporal

¿Cuándo se ejecuta el refresh?

El refresh automático se ejecuta:

  1. Al abrir la app – ContentView.onAppear() → checkSession()
  2. Cuando el usuario vuelve del background (implementaremos esto en el próximo artículo)
  3. Antes de hacer API calls (implementaremos esto cuando integremos APIs)

Ciclo de vida de los tokens

Login exitoso (Día 1)
    ↓
Access Token válido: 60 minutos
ID Token válido: 60 minutos
Refresh Token válido: 30 días
    ↓
Minuto 55 (primera vez que abre la app después de 55 min)
    ↓
TokenRefreshManager detecta: quedan < 5 minutos
    ↓
Refresh automático → Nuevos Access/ID tokens
    ↓
Cada vez que abre la app en los próximos 30 días:
    ↓
Si tokens expiran pronto → refresh automático
    ↓
Día 31 (Refresh Token expiró)
    ↓
Refresh falla → Tokens eliminados → SignInView → Re-login

Conclusión

En este artículo implementamos el refresh automático de tokens para nuestra app de AWS Cognito. Ahora los usuarios pueden usar la app durante 30 días sin tener que hacer login otra vez, siempre y cuando abran la app al menos una vez cada 60 minutos (o el refresh automático se encarga cuando vuelven).

Lo que logramos:

JWTDecoder – Decodificar tokens para obtener fecha de expiración ✅ refreshTokens() – Renovar Access/ID tokens usando Refresh Token ✅ TokenRefreshManager – Verificar y refrescar tokens automáticamente ✅ Integración en ContentView – Refresh al abrir la app ✅ Manejo de errores – Si Refresh Token expiró, forzar re-login

Flujo completo:

Usuario abre app
    → ContentView.checkSession()
    → TokenRefreshManager.checkAndRefreshTokensIfNeeded()
    → ¿Hay tokens guardados?
        NO → return false → SignInView
        SÍ → ¿Tokens válidos (> 5 min)?
            SÍ → return true → HomeView
            NO → ¿Refresh exitoso?
                SÍ → return true → HomeView
                NO → Limpiar tokens → return false → SignInView

¿Qué sigue?

En el próximo artículo implementaremos: – Refresh al volver del background – Si el usuario minimiza la app por 1 hora – Interceptor para API calls – Refrescar tokens antes de cada llamada a la API – Notificaciones de expiración – Avisar al usuario antes de que expire la sesión

Reflexión final

El refresh automático de tokens es CRÍTICO para una buena experiencia de usuario. Sin esto: – El usuario tendría que hacer login cada 60 minutos – Las API calls fallarían después de 1 hora – La app parecería inestable y con bugs

Con el refresh automático: – El usuario hace login UNA VEZ cada 30 días – La sesión es transparente y fluida – Los tokens siempre están actualizados

Mejores prácticas:

1. Threshold de 5 minutos – Refrescar ANTES de que expiren (da margen de error)

2. Limpiar tokens expirados – Si el Refresh Token falló, eliminar todo

3. Logging claro – Ayuda a debugging (ver cuándo y por qué se refrescan)

4. Manejo de errores robusto – Diferenciar entre “token expirado” vs “error de red”

José Luján es un desarrollador iOS y Android especializado en AWS y autor de múltiples artículos sobre integración de servicios cloud en aplicaciones móviles. También forma parte del programa Amazon Community Builders.

Disclaimer: Este artículo es con fines educativos. En producción, deberías: – Implementar refresh también cuando la app vuelve del background (NotificationCenter con UIApplication.willEnterForegroundNotification) – Agregar interceptor de red para refrescar tokens antes de CADA API call – Considerar usar librerías como Amplify que manejan esto automáticamente – Implementar retry logic si el refresh falla por error de red temporal – Agregar analytics para monitorear frecuencia de refresh y errores – Considerar políticas de seguridad: ¿permitir refresh indefinido o forzar re-login cada X días? – En algunos casos, validar el token en el backend además de solo verificar la expiración

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *