🔐 Authentication and User System
Index
- Opciones de Autenticación
- Implementación con Expo Auth
- Implementación con Backend Propio
- Implementación con Supabase
- Implementación con Firebase
- Contexto de Autenticación
- Protección de Rutas
- Flujos de Usuario
Opciones de Autenticación
| Opción | Complejidad | Ideal para | Pros | Contras |
|---|
| AsyncStorage (local) | Baja | Prototipos, apps offline | Simple, sin backend | Sin sincronización entre dispositivos |
| Supabase | Media | Apps con DB y auth | Auth + DB integrados, fácil setup | Dependencia externa |
| Firebase | Media | Apps con ecosistema Google | Escalable, muchos servicios | Más complejo, vendor lock-in |
| Backend propio (tRPC) | Alta | Control total | Personalizable | Más desarrollo y mantenimiento |
| Clerk/Auth0 | Baja | Empresas, seguridad crítica | Muy robusto, SSO | Costoso a escala |
Implementación con Expo Auth (OAuth)
Instalación
npx expo install expo-auth-session expo-crypto expo-web-browser
Google Sign-In
import * as Google from 'expo-auth-session/providers/google';
import * as WebBrowser from 'expo-web-browser';
import { useEffect } from 'react';
WebBrowser.maybeCompleteAuthSession();
export function useGoogleAuth() {
const [request, response, promptAsync] = Google.useAuthRequest({
expoClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID,
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
androidClientId: process.env.EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID,
webClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID,
});
useEffect(() => {
if (response?.type === 'success') {
const { authentication } = response;
fetchUserInfo(authentication?.accessToken);
}
}, [response]);
const fetchUserInfo = async (token: string | undefined) => {
if (!token) return;
const response = await fetch(
'https://www.googleapis.com/userinfo/v2/me',
{ headers: { Authorization: `Bearer ${token}` } }
);
const user = await response.json();
return user;
};
return {
signIn: () => promptAsync(),
isLoading: !request,
};
}
Apple Sign-In
import * as AppleAuthentication from 'expo-apple-authentication';
import { Platform } from 'react-native';
export function useAppleAuth() {
const signIn = async () => {
if (Platform.OS !== 'ios') {
throw new Error('Apple Sign-In only available on iOS');
}
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
return {
id: credential.user,
email: credential.email,
fullName: credential.fullName,
identityToken: credential.identityToken,
};
} catch (error: any) {
if (error.code === 'ERR_REQUEST_CANCELED') {
return null;
}
throw error;
}
};
return { signIn };
}
Implementación con Backend Propio
1. Estructura del Backend (tRPC)
backend/
├── trpc/
│ ├── auth/
│ │ ├── register/route.ts
│ │ ├── login/route.ts
│ │ ├── logout/route.ts
│ │ ├── me/route.ts
│ │ └── refresh/route.ts
│ └── app-router.ts
├── lib/
│ ├── jwt.ts
│ ├── password.ts
│ └── session.ts
└── hono.ts
2. Procedimientos de Auth
import { z } from 'zod';
import { publicProcedure } from '../../trpc';
import { hashPassword } from '../../lib/password';
import { createSession } from '../../lib/session';
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(2),
});
export const registerProcedure = publicProcedure
.input(registerSchema)
.mutation(async ({ input, ctx }) => {
const existingUser = await ctx.db.user.findUnique({
where: { email: input.email },
});
if (existingUser) {
throw new Error('El email ya está registrado');
}
const hashedPassword = await hashPassword(input.password);
const user = await ctx.db.user.create({
data: {
email: input.email,
password: hashedPassword,
name: input.name,
},
});
const session = await createSession(user.id);
return {
user: {
id: user.id,
email: user.email,
name: user.name,
},
token: session.token,
};
});
import { z } from 'zod';
import { publicProcedure } from '../../trpc';
import { verifyPassword } from '../../lib/password';
import { createSession } from '../../lib/session';
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
export const loginProcedure = publicProcedure
.input(loginSchema)
.mutation(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({
where: { email: input.email },
});
if (!user) {
throw new Error('Credenciales inválidas');
}
const isValid = await verifyPassword(input.password, user.password);
if (!isValid) {
throw new Error('Credenciales inválidas');
}
const session = await createSession(user.id);
return {
user: {
id: user.id,
email: user.email,
name: user.name,
avatar: user.avatar,
},
token: session.token,
};
});
import { protectedProcedure } from '../../trpc';
export const meProcedure = protectedProcedure
.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: ctx.userId },
select: {
id: true,
email: true,
name: true,
avatar: true,
createdAt: true,
},
});
if (!user) {
throw new Error('Usuario no encontrado');
}
return user;
});
3. Utilidades de Autenticación
import * as bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(password, hashedPassword);
}
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET!;
const EXPIRES_IN = '7d';
export interface TokenPayload {
userId: string;
email: string;
}
export function signToken(payload: TokenPayload): string {
return jwt.sign(payload, SECRET, { expiresIn: EXPIRES_IN });
}
export function verifyToken(token: string): TokenPayload | null {
try {
return jwt.verify(token, SECRET) as TokenPayload;
} catch {
return null;
}
}
Implementación con Supabase
Instalación
npx expo install @supabase/supabase-js @react-native-async-storage/async-storage
Configuración
import 'react-native-url-polyfill/auto';
import { createClient } from '@supabase/supabase-js';
import AsyncStorage from '@react-native-async-storage/async-storage';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});
Contexto de Auth con Supabase
import createContextHook from '@nkzw/create-context-hook';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/lib/supabase';
import { Session, User } from '@supabase/supabase-js';
interface AuthState {
user: User | null;
session: Session | null;
isLoading: boolean;
}
export const [AuthProvider, useAuth] = createContextHook(() => {
const queryClient = useQueryClient();
const [session, setSession] = useState<Session | null>(null);
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
});
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
queryClient.invalidateQueries({ queryKey: ['user'] });
}
);
return () => subscription.unsubscribe();
}, []);
const userQuery = useQuery({
queryKey: ['user', session?.user?.id],
queryFn: async () => {
if (!session?.user?.id) return null;
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', session.user.id)
.single();
if (error) throw error;
return data;
},
enabled: !!session?.user?.id,
});
const signUpMutation = useMutation({
mutationFn: async ({
email,
password,
name
}: {
email: string;
password: string;
name: string;
}) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: { name },
},
});
if (error) throw error;
return data;
},
});
const signInMutation = useMutation({
mutationFn: async ({
email,
password
}: {
email: string;
password: string;
}) => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
return data;
},
});
const signInWithOAuth = async (provider: 'google' | 'apple' | 'github') => {
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: 'myapp://auth/callback',
},
});
if (error) throw error;
return data;
};
const signOutMutation = useMutation({
mutationFn: async () => {
const { error } = await supabase.auth.signOut();
if (error) throw error;
},
onSuccess: () => {
queryClient.clear();
},
});
const resetPasswordMutation = useMutation({
mutationFn: async (email: string) => {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: 'myapp://auth/reset-password',
});
if (error) throw error;
},
});
return {
user: session?.user ?? null,
profile: userQuery.data,
session,
isLoading: userQuery.isLoading,
isAuthenticated: !!session,
signUp: signUpMutation.mutateAsync,
signIn: signInMutation.mutateAsync,
signInWithOAuth,
signOut: signOutMutation.mutateAsync,
resetPassword: resetPasswordMutation.mutateAsync,
isSigningUp: signUpMutation.isPending,
isSigningIn: signInMutation.isPending,
isSigningOut: signOutMutation.isPending,
};
});
Implementación con Firebase
Instalación
npx expo install firebase @react-native-firebase/app @react-native-firebase/auth
Configuración
import { initializeApp } from 'firebase/app';
import {
getAuth,
initializeAuth,
getReactNativePersistence
} from 'firebase/auth';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
const firebaseConfig = {
apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID,
};
const app = initializeApp(firebaseConfig);
export const auth = Platform.OS === 'web'
? getAuth(app)
: initializeAuth(app, {
persistence: getReactNativePersistence(AsyncStorage),
});
Contexto de Auth con Firebase
import createContextHook from '@nkzw/create-context-hook';
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
User,
signInWithEmailAndPassword,
createUserWithEmailAndPassword,
signOut as firebaseSignOut,
onAuthStateChanged,
sendPasswordResetEmail,
updateProfile,
} from 'firebase/auth';
import { auth } from '@/lib/firebase';
export const [FirebaseAuthProvider, useFirebaseAuth] = createContextHook(() => {
const queryClient = useQueryClient();
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUser(user);
setIsLoading(false);
});
return () => unsubscribe();
}, []);
const signUpMutation = useMutation({
mutationFn: async ({
email,
password,
name
}: {
email: string;
password: string;
name: string;
}) => {
const { user } = await createUserWithEmailAndPassword(auth, email, password);
await updateProfile(user, { displayName: name });
return user;
},
});
const signInMutation = useMutation({
mutationFn: async ({ email, password }: { email: string; password: string }) => {
const { user } = await signInWithEmailAndPassword(auth, email, password);
return user;
},
});
const signOutMutation = useMutation({
mutationFn: async () => {
await firebaseSignOut(auth);
queryClient.clear();
},
});
const resetPasswordMutation = useMutation({
mutationFn: async (email: string) => {
await sendPasswordResetEmail(auth, email);
},
});
return {
user,
isLoading,
isAuthenticated: !!user,
signUp: signUpMutation.mutateAsync,
signIn: signInMutation.mutateAsync,
signOut: signOutMutation.mutateAsync,
resetPassword: resetPasswordMutation.mutateAsync,
isSigningUp: signUpMutation.isPending,
isSigningIn: signInMutation.isPending,
};
});
Contexto de Autenticación (Genérico)
Estructura Recomendada
import createContextHook from '@nkzw/create-context-hook';
import { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface User {
id: string;
email: string;
name: string;
avatar?: string;
}
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
isAuthenticated: boolean;
}
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
export const [AuthProvider, useAuth] = createContextHook(() => {
const queryClient = useQueryClient();
const [token, setToken] = useState<string | null>(null);
const tokenQuery = useQuery({
queryKey: ['auth-token'],
queryFn: async () => {
const stored = await AsyncStorage.getItem(TOKEN_KEY);
return stored;
},
staleTime: Infinity,
});
useEffect(() => {
if (tokenQuery.data) {
setToken(tokenQuery.data);
}
}, [tokenQuery.data]);
const userQuery = useQuery({
queryKey: ['current-user', token],
queryFn: async () => {
if (!token) return null;
const response = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error('No autorizado');
}
return response.json();
},
enabled: !!token,
retry: false,
});
const loginMutation = useMutation({
mutationFn: async ({ email, password }: { email: string; password: string }) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Error de login');
}
return response.json();
},
onSuccess: async (data) => {
await AsyncStorage.setItem(TOKEN_KEY, data.token);
await AsyncStorage.setItem(USER_KEY, JSON.stringify(data.user));
setToken(data.token);
queryClient.setQueryData(['current-user', data.token], data.user);
},
});
const registerMutation = useMutation({
mutationFn: async ({
email,
password,
name
}: {
email: string;
password: string;
name: string;
}) => {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Error de registro');
}
return response.json();
},
onSuccess: async (data) => {
await AsyncStorage.setItem(TOKEN_KEY, data.token);
await AsyncStorage.setItem(USER_KEY, JSON.stringify(data.user));
setToken(data.token);
queryClient.setQueryData(['current-user', data.token], data.user);
},
});
const logoutMutation = useMutation({
mutationFn: async () => {
await AsyncStorage.multiRemove([TOKEN_KEY, USER_KEY]);
},
onSuccess: () => {
setToken(null);
queryClient.clear();
},
});
const updateProfileMutation = useMutation({
mutationFn: async (updates: Partial<User>) => {
const response = await fetch('/api/auth/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Error actualizando perfil');
return response.json();
},
onSuccess: (data) => {
queryClient.setQueryData(['current-user', token], data);
},
});
return {
user: userQuery.data ?? null,
token,
isLoading: tokenQuery.isLoading || userQuery.isLoading,
isAuthenticated: !!userQuery.data,
login: loginMutation.mutateAsync,
register: registerMutation.mutateAsync,
logout: logoutMutation.mutateAsync,
updateProfile: updateProfileMutation.mutateAsync,
isLoggingIn: loginMutation.isPending,
isRegistering: registerMutation.isPending,
loginError: loginMutation.error?.message,
registerError: registerMutation.error?.message,
};
});
Protección de Rutas
En el Layout Raíz
import { useAuth } from '@/contexts/AuthContext';
import { useRouter, useSegments } from 'expo-router';
import { useEffect } from 'react';
function useProtectedRoute() {
const { isAuthenticated, isLoading } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (isLoading) return;
const inAuthGroup = segments[0] === '(auth)';
const inProtectedGroup = segments[0] === '(tabs)';
if (!isAuthenticated && inProtectedGroup) {
router.replace('/login');
} else if (isAuthenticated && inAuthGroup) {
router.replace('/(tabs)/(home)');
}
}, [isAuthenticated, isLoading, segments]);
}
function RootLayoutNav() {
useProtectedRoute();
return (
<Stack>
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
);
}
Estructura de Archivos con Auth
app/
├── (auth)/ # Rutas públicas (login, register)
│ ├── _layout.tsx
│ ├── login.tsx
│ ├── register.tsx
│ └── forgot-password.tsx
├── (tabs)/ # Rutas protegidas
│ ├── _layout.tsx
│ └── ...
└── _layout.tsx
Flujos de Usuario
Pantalla de Login
import { View, StyleSheet } from 'react-native';
import { useState } from 'react';
import { useRouter } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext';
import { Input } from '@/components/Input';
import { Button } from '@/components/Button';
import { useToast } from '@/contexts/ToastContext';
export default function LoginScreen() {
const router = useRouter();
const { login, isLoggingIn } = useAuth();
const { showToast } = useToast();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const validate = () => {
const newErrors: typeof errors = {};
if (!email) {
newErrors.email = 'El email es requerido';
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = 'Email inválido';
}
if (!password) {
newErrors.password = 'La contraseña es requerida';
} else if (password.length < 8) {
newErrors.password = 'Mínimo 8 caracteres';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleLogin = async () => {
if (!validate()) return;
try {
await login({ email, password });
showToast('Bienvenido de nuevo', 'success');
} catch (error: any) {
showToast(error.message || 'Error al iniciar sesión', 'error');
}
};
return (
<View style={styles.container}>
<Input
label="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
error={errors.email}
testID="login-email"
/>
<Input
label="Contraseña"
value={password}
onChangeText={setPassword}
secureTextEntry
error={errors.password}
testID="login-password"
/>
<Button
title="Iniciar Sesión"
onPress={handleLogin}
loading={isLoggingIn}
testID="login-button"
/>
<Button
title="¿No tienes cuenta? Regístrate"
variant="ghost"
onPress={() => router.push('/register')}
/>
<Button
title="¿Olvidaste tu contraseña?"
variant="ghost"
onPress={() => router.push('/forgot-password')}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
justifyContent: 'center',
gap: 16,
},
});
Pantalla de Registro
import { View, StyleSheet, ScrollView } from 'react-native';
import { useState } from 'react';
import { useRouter } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext';
import { Input } from '@/components/Input';
import { Button } from '@/components/Button';
import { useToast } from '@/contexts/ToastContext';
export default function RegisterScreen() {
const router = useRouter();
const { register, isRegistering } = useAuth();
const { showToast } = useToast();
const [form, setForm] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = () => {
const newErrors: Record<string, string> = {};
if (!form.name || form.name.length < 2) {
newErrors.name = 'El nombre debe tener al menos 2 caracteres';
}
if (!form.email || !/\S+@\S+\.\S+/.test(form.email)) {
newErrors.email = 'Email inválido';
}
if (!form.password || form.password.length < 8) {
newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
}
if (form.password !== form.confirmPassword) {
newErrors.confirmPassword = 'Las contraseñas no coinciden';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleRegister = async () => {
if (!validate()) return;
try {
await register({
name: form.name,
email: form.email,
password: form.password,
});
showToast('Cuenta creada exitosamente', 'success');
} catch (error: any) {
showToast(error.message || 'Error al registrarse', 'error');
}
};
return (
<ScrollView contentContainerStyle={styles.container}>
<Input
label="Nombre"
value={form.name}
onChangeText={(text) => setForm({ ...form, name: text })}
error={errors.name}
testID="register-name"
/>
<Input
label="Email"
value={form.email}
onChangeText={(text) => setForm({ ...form, email: text })}
keyboardType="email-address"
autoCapitalize="none"
error={errors.email}
testID="register-email"
/>
<Input
label="Contraseña"
value={form.password}
onChangeText={(text) => setForm({ ...form, password: text })}
secureTextEntry
error={errors.password}
testID="register-password"
/>
<Input
label="Confirmar Contraseña"
value={form.confirmPassword}
onChangeText={(text) => setForm({ ...form, confirmPassword: text })}
secureTextEntry
error={errors.confirmPassword}
testID="register-confirm-password"
/>
<Button
title="Crear Cuenta"
onPress={handleRegister}
loading={isRegistering}
testID="register-button"
/>
<Button
title="¿Ya tienes cuenta? Inicia sesión"
variant="ghost"
onPress={() => router.push('/login')}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
padding: 24,
justifyContent: 'center',
gap: 16,
},
});
Checklist de Implementación