Persistencia de sesión con AWS Cognito en iOS

En el artículo anterior implementamos el almacenamiento seguro de tokens en Keychain y la funcionalidad de cerrar sesión. Sin embargo, hay un problema: cuando el usuario cierra y vuelve a abrir la app, siempre tiene que hacer login otra vez, incluso si los tokens siguen guardados en Keychain. En este artículo vamos a implementar la persistencia de sesión para que la app verifique automáticamente si hay una sesión activa al iniciar.

¿Qué vamos a construir?

En este artículo implementaremos:

  1. Verificación de tokens al iniciar – La app verifica si hay tokens guardados en Keychain
  2. Navegación inteligente – Si hay tokens → HomeView, si no hay → SignInView
  3. Experiencia fluida – El usuario no tiene que hacer login cada vez que abre la app
  4. Actualización de HomeView – Para que el cerrar sesión funcione correctamente con la persistencia

El problema actual

Estado actual (Artículo 4):

Usuario abre app → ContentView → SignInView (siempre)

Aunque los tokens estén guardados en Keychain, la app siempre inicia en SignInView. El usuario tiene que: 1. Abrir la app 2. Ingresar email y contraseña 3. Hacer login otra vez 4. Navegar a HomeView

Esto es molesto porque los tokens ya están guardados.

Lo que queremos (Artículo 5):

Usuario abre app → ContentView verifica tokens →
    ¿Hay tokens guardados?
        SÍ → HomeView (sesión persistente)
        NO → SignInView (pedir login)

El usuario solo hace login UNA VEZ, y la sesión se mantiene hasta que: – Los tokens expiren (60 minutos para Access/ID, 30 días para Refresh) – El usuario cierre sesión manualmente – El usuario elimine la app

Requisitos previos

  • Haber completado el Artículo 4 (Almacenamiento seguro de tokens)
  • Tener KeychainManager implementado
  • Tener HomeView con botón de cerrar sesión
  • Entender el flujo de navegación con NavigationStack

Paso 1: Actualizar ContentView para verificar tokens al iniciar

Vamos a modificar ContentView para que verifique si hay tokens guardados cuando la app se abre.

ContentView.swift:

import SwiftUI

struct ContentView: View {
    @State private var isLoggedIn: Bool = false
    @State private var isCheckingSession: Bool = true

    var body: some View {
        Group {
            if isCheckingSession {
                // Pantalla de carga mientras verifica tokens
                VStack {
                    ProgressView()
                        .scaleEffect(1.5)
                    Text("Verificando sesión...")
                        .padding(.top, 20)
                        .foregroundColor(.gray)
                }
            } else {
                // Mostrar vista según estado de sesión
                if isLoggedIn {
                    NavigationStack {
                        HomeView(isLoggedIn: $isLoggedIn)
                    }
                } else {
                    NavigationStack {
                        SignInView(isLoggedIn: $isLoggedIn)
                    }
                }
            }
        }
        .onAppear {
            checkSession()
        }
    }

    // MARK: - Verificar Sesión
    private func checkSession() {
        // Verificar si hay tokens guardados en Keychain
        let hasTokens = KeychainManager.shared.hasTokens()

        // Simular un pequeño delay para mostrar la pantalla de carga
        // (en producción esto sería el tiempo de validar tokens)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            isLoggedIn = hasTokens
            isCheckingSession = false

            if hasTokens {
                print("✅ Sesión encontrada - Navegando a HomeView")
            } else {
                print("⚠️ No hay sesión - Mostrando SignInView")
            }
        }
    }
}

¿Qué cambió aquí?

  1. Dos nuevos @State:
    1. isLoggedIn: Bool – Indica si el usuario tiene sesión activa
    1. isCheckingSession: Bool – Indica si estamos verificando tokens (para mostrar loading)
  2. Tres estados posibles de UI:
    1. isCheckingSession = true → Muestra ProgressView con “Verificando sesión…”
    1. isLoggedIn = true → Muestra HomeView dentro de NavigationStack
    1. isLoggedIn = false → Muestra SignInView dentro de NavigationStack
  3. @Binding compartido:
    1. Pasamos $isLoggedIn tanto a HomeView como a SignInView
    1. Cuando SignInView hace login exitoso → isLoggedIn = true → ContentView muestra HomeView
    1. Cuando HomeView cierra sesión → isLoggedIn = false → ContentView muestra SignInView
    1. No necesitamos dismiss() ni NavigationLink porque ContentView observa el binding
  4. checkSession() al iniciar:
    1. Se ejecuta en .onAppear
    1. Llama a KeychainManager.shared.hasTokens() para verificar si hay tokens
    1. Delay de 0.5 segundos para mostrar la pantalla de carga (mejor UX)
    1. Actualiza isLoggedIn e isCheckingSession
  5. Navegación inteligente:
    1. Si hasTokens = true → va directo a HomeView (sesión persistente)
    1. Si hasTokens = false → muestra SignInView (pedir login)

¿Por qué NavigationStack separados?

Usamos dos NavigationStack diferentes (uno para HomeView, otro para SignInView) en lugar de un solo NavigationStack porque: – Cuando cambia el binding de isLoggedIn, SwiftUI reconstruye completamente el Group – Esto resetea el navigation stack y asegura que no haya “backstack” acumulado – El usuario no puede hacer “back” de HomeView a SignInView (porque son stacks separados)

Paso 2: Actualizar SignInView para usar @Binding

Ahora modificamos SignInView para que actualice el estado compartido en lugar de navegar manualmente.

Cambios en SignInView.swift:

struct SignInView: View {
    @Binding var isLoggedIn: Bool  // ✅ NUEVO - Recibe binding del ContentView

    @State private var email: String = ""
    @State private var password: String = ""
    @State private var isLoading: Bool = false
    @State private var message: String = ""
    @State private var showAlert: Bool = false
    @State private var navigateToRegister: Bool = false
    // ❌ ELIMINADO: @State private var navigateToHome: Bool = false

    private let authService = CognitoAuthService()

    var body: some View {
        VStack(spacing: 20) {
            // ... (UI igual que antes) ...
        }
        .navigationDestination(isPresented: $navigateToRegister) {
            RegisterView()
        }
        // ❌ ELIMINADO: .navigationDestination(isPresented: $navigateToHome)
    }

    // MARK: - Función de Inicio de Sesión
    private func signIn() {
        guard !email.isEmpty, !password.isEmpty else {
            message = "Por favor completa todos los campos"
            showAlert = true
            return
        }

        isLoading = true
        message = "Iniciando sesión..."

        Task {
            do {
                let tokens = try await authService.signIn(email: email, password: password)

                // Guardar tokens en Keychain
                let saved = KeychainManager.shared.saveTokens(tokens)

                await MainActor.run {
                    isLoading = false

                    if saved {
                        message = "✅ Inicio de sesión exitoso"
                        print("✅ Tokens guardados en Keychain")

                        // Limpiar campos
                        email = ""
                        password = ""

                        // ✅ CAMBIÓ: Actualizar binding en lugar de navegar
                        isLoggedIn = true  // ContentView detecta el cambio y muestra HomeView
                    } else {
                        message = "Error al guardar tokens"
                        showAlert = true
                    }
                }
            } catch {
                await MainActor.run {
                    isLoading = false
                    message = error.localizedDescription
                    showAlert = true
                }
            }
        }
    }
}

#Preview {
    SignInView(isLoggedIn: .constant(false))  // ✅ Proporcionar binding en Preview
}

¿Qué cambió aquí?

  1. Agregamos @Binding:
    • @Binding var isLoggedIn: Bool
      • SignInView ya no maneja la navegación por sí mismo
      • Recibe el binding de ContentView
      • Cuando actualiza isLoggedIn = true, ContentView automáticamente muestra HomeView
  2. Eliminamos navegación local:
    • Eliminamos @State private var navigateToHome: Bool
    • Eliminamos .navigationDestination(isPresented: $navigateToHome) { HomeView() }
    • Ya no necesitamos manejar la navegación porque ContentView lo hace por nosotros
  3. Actualizamos signIn():
    • Antes: navigateToHome = true → navegaba manualmente
    • Ahora: isLoggedIn = true → actualiza el binding
    • ContentView observa el cambio y muestra HomeView automáticamente
  4. Preview actualizado:
    • SignInView(isLoggedIn: .constant(false))
    • Usamos .constant(false) porque el Preview no necesita un binding real

Paso 3: Actualizar HomeView para usar @Binding

Finalmente, actualizamos HomeView para que también use el binding compartido.

Cambios en HomeView.swift:

import SwiftUI

struct HomeView: View {
    @Binding var isLoggedIn: Bool  // ✅ NUEVO - Recibe binding del ContentView
    // ❌ ELIMINADO: @Environment(\.dismiss) private var dismiss
    @State private var showingSignOut = false

    var body: some View {
        VStack(spacing: 30) {
            Spacer()

            // Icono de bienvenida
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: 80))
                .foregroundColor(.green)

            // Mensaje de bienvenida
            Text("¡Bienvenido!")
                .font(.largeTitle)
                .fontWeight(.bold)

            Text("Has iniciado sesión exitosamente")
                .font(.body)
                .foregroundColor(.gray)

            Text("Tus tokens están guardados de forma segura en Keychain")
                .font(.caption)
                .foregroundColor(.gray)
                .multilineTextAlignment(.center)
                .padding(.horizontal, 40)

            Spacer()

            // Botón de cerrar sesión
            Button(action: {
                showingSignOut = true
            }) {
                Label("Cerrar Sesión", systemImage: "arrow.right.square")
                    .fontWeight(.semibold)
                    .foregroundColor(.white)
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(Color.red)
                    .cornerRadius(10)
            }
            .padding(.horizontal, 30)
            .padding(.bottom, 50)
        }
        .navigationBarBackButtonHidden(true)
        .alert("Cerrar Sesión", isPresented: $showingSignOut) {
            Button("Cancelar", role: .cancel) { }
            Button("Cerrar Sesión", role: .destructive) {
                signOut()
            }
        } message: {
            Text("¿Estás seguro que deseas cerrar sesión?")
        }
    }

    // MARK: - Cerrar Sesión
    private func signOut() {
        // Eliminar tokens del Keychain
        let success = KeychainManager.shared.deleteTokens()

        if success {
            print("✅ Tokens eliminados. Sesión cerrada.")
            // ✅ CAMBIÓ: Actualizar binding en lugar de dismiss()
            isLoggedIn = false  // ContentView detecta el cambio y muestra SignInView
        } else {
            print("❌ Error al eliminar tokens")
        }
    }
}

#Preview {
    HomeView(isLoggedIn: .constant(true))  // ✅ Proporcionar binding en Preview
}

¿Qué cambió aquí?

  1. Agregamos @Binding:
    • @Binding var isLoggedIn: Bool
      • HomeView ya no usa @Environment(\.dismiss) para volver atrás
      • Recibe el binding de ContentView
      • Cuando actualiza isLoggedIn = false, ContentView automáticamente muestra SignInView
  2. Eliminamos @Environment(.dismiss):
    • Ya no necesitamos dismiss() porque no estamos navegando dentro de un NavigationStack
    • ContentView maneja el cambio de vista al observar el binding
  3. Actualizamos signOut():
    • Antes: dismiss() → volvía atrás en el navigation stack
    • Ahora: isLoggedIn = false → actualiza el binding
    • ContentView observa el cambio y muestra SignInView automáticamente
  4. Preview actualizado:
    • HomeView(isLoggedIn: .constant(true))
    • Usamos .constant(true) porque queremos ver HomeView en el Preview

Paso 4: Probar el flujo completo

Ahora vamos a probar todo el flujo de persistencia de sesión:

Prueba 1: Primera vez (sin tokens)

  • 1. Abrir la app por primera vez
    • Verás “Verificando sesión…” por 0.5 segundos
    • Luego aparece SignInView
    • Console: ⚠️ No hay sesión – Mostrando SignInView
  • 2. Hacer login
    • Ingresa email y contraseña
    • Presiona “Iniciar Sesión”
    • Console: ✅ Tokens guardados en Keychain
    • La app automáticamente muestra HomeView
    • ¿Qué pasó internamente? SignInView actualizó isLoggedIn = true → ContentView observó el cambio → mostró HomeView

Prueba 2: Cerrar sesión

  • 3. Cerrar sesión desde HomeView
    • Presiona “Cerrar Sesión”Confirma en el alertConsole: ✅ Tokens eliminados. Sesión cerrada.La app automáticamente vuelve a SignInView
    • ¿Qué pasó internamente? HomeView actualizó isLoggedIn = false → ContentView observó el cambio → mostró SignInView

Prueba 3: Persistencia de sesión (el objetivo principal)

  • Hacer login otra vez
    • Ingresa email y contraseña
    • Presiona “Iniciar Sesión”
    • Llegas a HomeView
    • Console: ✅ Tokens guardados en Keychain
  • Cerrar la app (CMD+Q o stop en Xcode)
  • Volver a abrir la app
    • Verás “Verificando sesión…” por 0.5 segundos
    • La app va DIRECTAMENTE a HomeView (sin pedir login)
    • Console: ✅ Sesión encontrada – Navegando a HomeView
    • ¡FUNCIONÓ! La sesión persistió
  • Cerrar sesión y volver a abrir
    • Presiona “Cerrar Sesión”
    • Cierra la app (CMD+Q)
    • Vuelve a abrir
    • Ahora SÍ pide login (porque eliminaste los tokens)
    • Console: ⚠️ No hay sesión – Mostrando SignInView

Qué hace que la sesión persista?

  • Keychain: Los tokens se guardan en Keychain, que persiste entre ejecuciones de la app
  • checkSession(): Al abrir, ContentView verifica si hay tokens
  • hasTokens(): KeychainManager revisa si hay Access, ID y Refresh tokens guardados
  • Navegación automática: Si hay tokens → HomeView, si no hay → SignInView

¿Cuándo se pierde la sesión?

La sesión se pierde en estos casos: 1. Usuario cierra sesión: Presiona “Cerrar Sesión” → deleteTokens() elimina tokens de Keychain 2. Tokens expiran: Access/ID (60 min), Refresh (30 días) – En artículos futuros implementaremos refresh automático 3. Usuario desinstala la app: Keychain se limpia al desinstalar 4. Usuario cambia de dispositivo: Keychain es local al dispositivo

Conclusión

En este artículo implementamos la persistencia de sesión para nuestra app de AWS Cognito. Ahora el usuario solo necesita hacer login UNA VEZ, y la sesión se mantiene incluso si cierra y vuelve a abrir la app.

Lo que logramos:

Verificación de tokens al iniciar – ContentView verifica Keychain en .onAppear

Navegación inteligente – Si hay tokens → HomeView, si no → SignInView

Estado compartido con @Binding – ContentView maneja el estado, SignInView y HomeView lo actualizan

Experiencia fluida – No más login en cada apertura de la app

Manejo correcto de cerrar sesión – Elimina tokens y vuelve a SignInView automáticamente

Flujo completo:

App abre
    → ContentView.onAppear()
    → checkSession()
    → KeychainManager.hasTokens()
    → ¿Hay tokens?
        SÍ → isLoggedIn = true → HomeView
        NO → isLoggedIn = false → SignInView

Usuario hace login
    → SignInView.signIn()
    → KeychainManager.saveTokens()
    → isLoggedIn = true
    → ContentView observa cambio → HomeView

Usuario cierra sesión
    → HomeView.signOut()
    → KeychainManager.deleteTokens()
    → isLoggedIn = false
    → ContentView observa cambio → SignInView

¿Qué sigue?

En el próximo artículo implementaremos: – Refresh Token automático – Renovar Access/ID tokens antes de que expiren – Manejo de expiración – Detectar cuando los tokens expiran y forzar re-login – Validación de tokens – Verificar que los tokens sean válidos (no solo que existan)

Reflexión final

Este artículo completa el flujo básico de autenticación: 1. Artículo 1-2: Setup de Cognito y registro de usuarios 2. Artículo 3: Login y obtención de tokens 3. Artículo 4: Almacenamiento seguro en Keychain 4. Artículo 5 (este): Persistencia de sesión

Ahora tenemos una app funcional donde: – Los usuarios se registran una vez – Hacen login una vez – La sesión persiste entre ejecuciones – Pueden cerrar sesión cuando quieran

En los siguientes artículos agregaremos funcionalidades más avanzadas como refresh automático, validación de tokens, y manejo de errores de red.

José Luján es un desarrollador iOS 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: – Validar los tokens antes de confiar en ellos (verificar firma, expiración, etc.) – Implementar refresh automático antes de que expiren – Manejar casos de error de red (¿qué pasa si no hay internet al abrir la app?) – Agregar biometría (Face ID/Touch ID) como capa adicional de seguridad – Implementar logging y analytics para monitorear sesiones – Considerar políticas de expiración de sesión por inactividad

Deja una respuesta

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