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:
- Verificación de tokens al iniciar – La app verifica si hay tokens guardados en Keychain
- Navegación inteligente – Si hay tokens → HomeView, si no hay → SignInView
- Experiencia fluida – El usuario no tiene que hacer login cada vez que abre la app
- 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í?
- Dos nuevos @State:
- isLoggedIn: Bool – Indica si el usuario tiene sesión activa
- isCheckingSession: Bool – Indica si estamos verificando tokens (para mostrar loading)
- Tres estados posibles de UI:
- isCheckingSession = true → Muestra ProgressView con “Verificando sesión…”
- isLoggedIn = true → Muestra HomeView dentro de NavigationStack
- isLoggedIn = false → Muestra SignInView dentro de NavigationStack
- @Binding compartido:
- Pasamos $isLoggedIn tanto a HomeView como a SignInView
- Cuando SignInView hace login exitoso → isLoggedIn = true → ContentView muestra HomeView
- Cuando HomeView cierra sesión → isLoggedIn = false → ContentView muestra SignInView
- No necesitamos dismiss() ni NavigationLink porque ContentView observa el binding
- checkSession() al iniciar:
- Se ejecuta en .onAppear
- Llama a KeychainManager.shared.hasTokens() para verificar si hay tokens
- Delay de 0.5 segundos para mostrar la pantalla de carga (mejor UX)
- Actualiza isLoggedIn e isCheckingSession
- Navegación inteligente:
- Si hasTokens = true → va directo a HomeView (sesión persistente)
- 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í?
- 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
- @Binding var isLoggedIn: Bool
- 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
- Actualizamos signIn():
- Antes: navigateToHome = true → navegaba manualmente
- Ahora: isLoggedIn = true → actualiza el binding
- ContentView observa el cambio y muestra HomeView automáticamente
- 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í?
- 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
- @Binding var isLoggedIn: Bool
- 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
- 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
- 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