Conceptos clave: autenticación vs autorización
Autenticación responde a “¿quién eres?” (login, sesión, tokens). Autorización responde a “¿qué puedes hacer?” (permisos, roles, acceso a pantallas/acciones). En una app React Native profesional, ambos se implementan de forma consistente: la UI consulta el estado de sesión y permisos, el dominio decide reglas, y los servicios hablan con el backend usando encabezados de autorización.
Modelo típico con tokens
- Access token: corto (minutos). Se envía en cada request:
Authorization: Bearer <token>. - Refresh token: más largo (días/semanas). Se usa para obtener un nuevo access token cuando expira.
- Expiración: el backend devuelve 401 (no autenticado) o 403 (prohibido) según el caso.
Arquitectura recomendada: UI, dominio y servicios
Para mantener claridad y seguridad, separa responsabilidades:
- UI (pantallas/componentes): muestra formularios, estados de carga/error, y navega. No conoce detalles de tokens.
- Dominio (casos de uso): orquesta login/logout/refresh, decide qué hacer ante 401/403, y expone un estado de sesión.
- Servicios (API + almacenamiento seguro): implementan llamadas HTTP, interceptores, y guardan/leen tokens de forma segura.
Una estructura posible:
src/ domain/ auth/ types.ts authStore.ts useCases.ts services/ http/ client.ts interceptors.ts auth/ tokenStorage.ts authApi.ts ui/ navigation/ guards.tsxAlmacenamiento seguro de tokens y credenciales
Evita guardar tokens en AsyncStorage. Para tokens, usa almacenamiento seguro del sistema (Keychain en iOS, Keystore en Android). Opciones comunes: react-native-keychain o expo-secure-store (si usas Expo). El objetivo es reducir exposición ante backups, inspección de archivos o apps maliciosas.
TokenStorage (servicio)
Define una interfaz para no acoplarte a una librería concreta:
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
// src/domain/auth/types.ts
export type SessionTokens = {
accessToken: string;
refreshToken: string;
expiresAt: number; // epoch ms
};
export interface TokenStorage {
get(): Promise<SessionTokens | null>;
set(tokens: SessionTokens): Promise<void>;
clear(): Promise<void>;
}Implementación ejemplo (pseudo) con un storage seguro:
// src/services/auth/tokenStorage.ts
import * as SecureStore from 'expo-secure-store';
import { SessionTokens, TokenStorage } from '../../domain/auth/types';
const KEY = 'session_tokens';
export const tokenStorage: TokenStorage = {
async get() {
const raw = await SecureStore.getItemAsync(KEY);
return raw ? (JSON.parse(raw) as SessionTokens) : null;
},
async set(tokens) {
await SecureStore.setItemAsync(KEY, JSON.stringify(tokens));
},
async clear() {
await SecureStore.deleteItemAsync(KEY);
},
};Medidas adicionales para reducir exposición
- No persistir datos sensibles innecesarios: evita guardar email, roles o perfil completo si no es imprescindible.
- Minimiza logs: no imprimas tokens ni respuestas completas en consola; sanitiza errores.
- Protege capturas en pantallas sensibles (según plataforma) y evita mostrar tokens en UI.
- Evita “remember me” inseguro: si lo implementas, que sea mediante refresh token y revocación en backend, no guardando contraseña.
Servicio de autenticación: login, logout y refresh
API de autenticación (servicio)
// src/services/auth/authApi.ts
import { http } from '../http/client';
import { SessionTokens } from '../../domain/auth/types';
export type LoginDTO = { email: string; password: string };
export const authApi = {
async login(dto: LoginDTO): Promise<SessionTokens> {
const { data } = await http.post('/auth/login', dto);
return data;
},
async refresh(refreshToken: string): Promise<SessionTokens> {
const { data } = await http.post('/auth/refresh', { refreshToken });
return data;
},
async logout(refreshToken: string): Promise<void> {
await http.post('/auth/logout', { refreshToken });
},
};Casos de uso (dominio)
El dominio decide cómo se guarda la sesión y qué ocurre al cerrar sesión o expirar tokens:
// src/domain/auth/useCases.ts
import { authApi } from '../../services/auth/authApi';
import { tokenStorage } from '../../services/auth/tokenStorage';
import { SessionTokens } from './types';
export async function login(email: string, password: string) {
const tokens = await authApi.login({ email, password });
await tokenStorage.set(tokens);
return tokens;
}
export async function logout() {
const current = await tokenStorage.get();
if (current?.refreshToken) {
try { await authApi.logout(current.refreshToken); } catch {}
}
await tokenStorage.clear();
}
export async function refreshSession(): Promise<SessionTokens | null> {
const current = await tokenStorage.get();
if (!current?.refreshToken) return null;
const tokens = await authApi.refresh(current.refreshToken);
await tokenStorage.set(tokens);
return tokens;
}Encabezados de autorización y cliente HTTP con interceptores
Centraliza la lógica en un cliente HTTP. Así evitas repetir Authorization en cada request y puedes manejar expiración de forma uniforme.
Cliente HTTP (Axios como ejemplo)
// src/services/http/client.ts
import axios from 'axios';
export const http = axios.create({
baseURL: 'https://api.tuapp.com',
timeout: 15000,
});Interceptor de request: adjuntar access token
// src/services/http/interceptors.ts
import { http } from './client';
import { tokenStorage } from '../auth/tokenStorage';
export function attachAuthInterceptors() {
http.interceptors.request.use(async (config) => {
const session = await tokenStorage.get();
if (session?.accessToken) {
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${session.accessToken}`;
}
return config;
});
}Interceptor de response: refresco automático ante 401
Patrón recomendado: si una request falla con 401 por expiración, intenta refrescar tokens una sola vez, reintenta la request original y, si falla el refresh, fuerza logout. También debes evitar múltiples refresh simultáneos (cola/lock).
// src/services/http/interceptors.ts
import { http } from './client';
import { tokenStorage } from '../auth/tokenStorage';
import { authApi } from '../auth/authApi';
let refreshPromise: Promise<string | null> | null = null;
async function getNewAccessToken(): Promise<string | null> {
if (!refreshPromise) {
refreshPromise = (async () => {
const session = await tokenStorage.get();
if (!session?.refreshToken) return null;
const newTokens = await authApi.refresh(session.refreshToken);
await tokenStorage.set(newTokens);
return newTokens.accessToken;
})().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
export function attachAuthInterceptors() {
http.interceptors.response.use(
(res) => res,
async (error) => {
const status = error?.response?.status;
const original = error.config;
if (status === 401 && original && !original._retry) {
original._retry = true;
const newAccess = await getNewAccessToken();
if (newAccess) {
original.headers = original.headers ?? {};
original.headers.Authorization = `Bearer ${newAccess}`;
return http.request(original);
}
}
return Promise.reject(error);
}
);
}Notas prácticas:
- La marca
_retryevita bucles infinitos. - El
refreshPromiseevita que 5 requests simultáneas disparen 5 refresh. - Si el refresh falla (refresh token revocado/expirado), el dominio debe limpiar sesión y redirigir a login.
Manejo global de errores 401/403
Conviene traducir errores HTTP a eventos de sesión y mensajes de UI consistentes. Reglas típicas:
- 401: no autenticado o sesión expirada. Intenta refresh; si no se puede, logout y redirige a flujo público.
- 403: autenticado pero sin permisos. No intentes refresh; muestra pantalla/alerta de “sin permisos” y registra el evento.
Normalización de errores (servicio)
// src/services/http/errors.ts
export type AppError = {
kind: 'Network' | 'Unauthorized' | 'Forbidden' | 'Server' | 'Unknown';
message: string;
status?: number;
};
export function mapHttpError(err: any): AppError {
const status = err?.response?.status;
if (!status) return { kind: 'Network', message: 'Error de red o timeout' };
if (status === 401) return { kind: 'Unauthorized', message: 'Sesión no válida', status };
if (status === 403) return { kind: 'Forbidden', message: 'No tienes permisos', status };
if (status >= 500) return { kind: 'Server', message: 'Error del servidor', status };
return { kind: 'Unknown', message: 'Error inesperado', status };
}Reacción desde el dominio
En vez de que cada pantalla decida qué hacer con un 401, centraliza: cuando el interceptor no logra refrescar, dispara un evento de “sesión expirada” (por ejemplo, mediante un store global o un event emitter) y ejecuta logout().
// idea: un canal simple de eventos
export const authEvents = {
onSessionExpired: (cb: () => void) => { /* subscribe */ },
emitSessionExpired: () => { /* emit */ },
};En el interceptor, si no hay refresh posible:
import { authEvents } from '../../domain/auth/authEvents';
// ... dentro del response interceptor
if (status === 401 && !newAccess) {
authEvents.emitSessionExpired();
}Protección de rutas (route guards) en navegación
La protección de rutas consiste en impedir que el usuario llegue a pantallas privadas si no está autenticado, y en algunos casos, si no tiene el rol adecuado. La forma más limpia es tener un “árbol” de navegación público y otro privado, y elegir cuál renderizar según el estado de sesión.
Estado de sesión mínimo
En el store de auth, guarda solo lo necesario para decidir navegación:
isAuthenticateduser(id, roles) si aplicastatus:idle|loading|authenticated|anonymous
Guard de autenticación
// src/ui/navigation/guards.tsx
import React from 'react';
type Props = {
isAuthenticated: boolean;
PublicNavigator: React.ReactElement;
PrivateNavigator: React.ReactElement;
};
export function AuthGate({ isAuthenticated, PublicNavigator, PrivateNavigator }: Props) {
return isAuthenticated ? PrivateNavigator : PublicNavigator;
}En el arranque de la app, intenta restaurar sesión leyendo tokens del storage seguro y, si corresponde, refrescando:
- Lee tokens.
- Si
expiresAtestá cerca o expiró, intentarefresh. - Si falla, limpia y queda en flujo público.
Control por roles y permisos
Roles (por ejemplo: admin, manager, user) deben venir del backend firmados dentro del token (JWT) o en un endpoint de perfil. En el cliente, úsalos para:
- Ocultar/mostrar opciones de UI (conveniencia).
- Bloquear navegación a pantallas restringidas (seguridad de UX).
- Evitar ejecutar acciones no permitidas (validación previa).
Importante: la seguridad real se aplica en backend; el cliente solo reduce intentos y mejora experiencia.
Helpers de autorización (dominio)
// src/domain/auth/permissions.ts
export type Role = 'admin' | 'manager' | 'user';
export function hasRole(userRoles: Role[] | undefined, required: Role[]) {
if (!userRoles) return false;
return required.some((r) => userRoles.includes(r));
}Guard por rol para pantallas
// src/ui/navigation/RoleGate.tsx
import React from 'react';
import { hasRole, Role } from '../../domain/auth/permissions';
type Props = {
roles?: Role[];
required: Role[];
children: React.ReactNode;
fallback?: React.ReactNode;
};
export function RoleGate({ roles, required, children, fallback = null }: Props) {
if (!hasRole(roles, required)) return <>{fallback}</>;
return <>{children}</>;
}Uso típico: envolver botones o secciones sensibles, y en navegación redirigir a una pantalla “Sin permisos” si el rol no cumple.
Guía práctica paso a paso: implementar login/logout, refresh y protección
Paso 1: definir contrato de sesión y storage seguro
- Crea
SessionTokensconaccessToken,refreshToken,expiresAt. - Implementa
TokenStoragecon SecureStore/Keychain.
Paso 2: crear Auth API y casos de uso
authApi.login,authApi.refresh,authApi.logout.- Casos de uso:
login(),refreshSession(),logout().
Paso 3: configurar cliente HTTP e interceptores
- Request interceptor: adjunta
Authorization. - Response interceptor: si 401, intenta refresh con lock; reintenta request; si no, emite “session expired”.
Paso 4: restauración de sesión al iniciar
- En el bootstrap de la app, lee tokens.
- Si no hay tokens: estado anónimo.
- Si hay tokens y están vigentes: estado autenticado.
- Si expiraron: intenta refresh; si falla: limpia y anónimo.
Paso 5: navegación protegida
- Renderiza árbol público o privado con
AuthGate. - Para pantallas con rol: usa
RoleGateo un guard en la definición de rutas.
Paso 6: manejo consistente de 403
- Cuando recibas 403, muestra fallback “Sin permisos” y evita reintentos.
- Registra el evento (sin datos sensibles) para diagnóstico.
Manejo de expiración: estrategias prácticas
Refresh proactivo vs reactivo
- Reactivo: refrescar cuando llega un 401 (vía interceptor). Simple y efectivo.
- Proactivo: refrescar antes de expirar (por ejemplo, si faltan < 60s). Reduce fallos visibles en UI.
Ejemplo de chequeo proactivo:
const SKEW_MS = 60_000;
const session = await tokenStorage.get();
const needsRefresh = session && Date.now() + SKEW_MS >= session.expiresAt;Evitar condiciones de carrera
- Usa un lock/cola (
refreshPromise) para que solo haya un refresh activo. - Marca requests reintentadas con
_retry. - Si el refresh falla, limpia tokens una sola vez y notifica al resto.
Buenas prácticas de seguridad aplicadas
Reducir superficie de ataque en el cliente
- Principio de mínimo privilegio: no asumas permisos; consulta roles/permisos y limita UI.
- Sanitiza errores: muestra mensajes genéricos; guarda detalles solo en herramientas internas y sin tokens.
- Evita exponer PII: en pantallas de perfil, muestra lo necesario; en cachés, cifra o evita persistencia.
- Pinning/SSL (si aplica): considera certificate pinning en apps de alto riesgo (banca/health). Evalúa impacto en mantenimiento.
- Protección ante debugging: en builds de producción, deshabilita logs y herramientas de debug; revisa configuración de minificación.
Checklist rápido de implementación
| Área | Qué verificar |
|---|---|
| Tokens | Access corto, refresh largo, rotación y revocación soportadas por backend |
| Storage | Tokens en SecureStore/Keychain/Keystore, no en AsyncStorage |
| HTTP | Authorization centralizado, refresh con lock, reintentos controlados |
| Errores | 401 refresca o expira sesión; 403 bloquea por permisos |
| Navegación | Árbol público/privado, guards por rol |
| Datos sensibles | No logs de tokens, no persistir PII innecesaria |