Sesión y almacenamiento seguro
La sesión es el único estado global de la app. Vive en context/auth-context.tsx y se consume
con useAuth().
const { user, accessToken, isLoading, login, logout } = useAuth();
Qué se persiste, y dónde
En expo-secure-store, con claves centralizadas en services/secure-storage.ts y prefijadas
(petid.accessToken, petid.refreshToken, petid.userId…). Se guardan los dos tokens y los datos
mínimos de identidad, para poder pintar la pantalla sin esperar a la red al abrir la app.
Nunca se guarda ahí nada que la api pueda dar: el perfil completo, las mascotas o los ajustes se piden cada vez.
Dos detalles del módulo de almacenamiento:
- Las claves son un objeto
as const, no cadenas sueltas. Una errata en una clave es un bug silencioso —leesnullpara siempre—, y así no compila. - En web no hace nada.
expo-secure-storeno tiene implementación web y lanzaría, así que el módulo se comporta como “no hay nada guardado” en vez de romper.
El arranque
Un efecto único al montar AuthProvider lee todo de golpe con Promise.all, y mientras
isLoading está en true. El layout raíz no pinta nada hasta que la sesión, las fuentes y el i18n
están listos, y solo entonces esconde el splash:
const ready = fontsLoaded && !isLoading && i18nReady;
if (!ready) return null;
Así se evita el parpadeo de ver la pantalla de login un instante antes de restaurar la sesión.
El puente hacia apiFetch
Cada vez que los tokens cambian —login, logout, restauración— el contexto los empuja a las variables de módulo del cliente HTTP:
setApiAccessToken(success.accessToken);
setApiRefreshToken(success.refreshToken ?? null);
Y en el otro sentido, el contexto registra un handler para que el cliente pueda devolver la app al estado no autenticado cuando un refresh falla:
setSessionExpiredHandler(() => { clearSession(); });
Ese es el único camino por el que una sesión muere sola. No hay temporizadores ni comprobaciones periódicas: se descubre al fallar una petición.
Login en dos pasos
login() no siempre deja la sesión lista, porque puede haber 2FA. Lo devuelve en el tipo, en vez de
lanzar o de devolver un booleano ambiguo:
export type LoginOutcome =
| { mfaRequired: false }
| { mfaRequired: true; challengeToken: string };
La pantalla mira mfaRequired y navega al reto si hace falta. Los dos caminos —login directo y
completeMfaLogin()— acaban en la misma función privada completeSession(), así que la sesión se
persiste en un solo sitio.
Si añades un tercer camino de entrada, hazlo terminar ahí también.
El logout hace más de lo que parece
await authApi.logout().catch(() => undefined);
if (deviceToken) {
await unregisterDevice(deviceToken).catch(() => undefined);
await deleteSecureItem(SecureStorageKeys.deviceToken);
}
await clearSession();
Tres cosas, y en este orden: avisar al servidor, dar de baja el token de push —si no, el dispositivo seguiría recibiendo avisos de una cuenta de la que ya salió— y limpiar lo local.
Los dos .catch(() => undefined) son deliberados: si la red falla, el logout local tiene que
ocurrir igual. Lo contrario dejaría al usuario atrapado dentro de la app.