← Volver al listado de tecnologías
Capítulo 8: Implementación de Autenticación - SignIn, SignUp y OAuth
Capítulo 8: Implementación de Autenticación - SignIn, SignUp y OAuth
En este capítulo implementaremos los componentes de autenticación completos, incluyendo formularios de login y registro, OAuth con Google y Apple, y toda la lógica de flujos de autenticación.
🎯 Objetivos del Capítulo
- Crear componentes de SignIn y SignUp
- Implementar OAuth con Google y Apple
- Manejar verificación de email y errores
- Crear protección de rutas
- Testing completo de flujos de autenticación
📋 Prerrequisitos
- Capítulo 7 completado (Configuración de Clerk)
- Cuentas configuradas en Google Cloud y Apple Developer
- Variables de entorno establecidas
🏗️ Estructura de Componentes de Autenticación
Primero, creemos la estructura de carpetas para nuestros componentes:
// src/components/auth/
// ├── SignInScreen.tsx
// ├── SignUpScreen.tsx
// ├── OAuthButtons.tsx
// ├── AuthForm.tsx
// └── __tests__/
🔐 Componente SignIn
// src/components/auth/SignInScreen.tsx
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { useSignIn } from '@clerk/clerk-expo';
import { OAuthButtons } from './OAuthButtons';
export const SignInScreen: React.FC = () => {
const { signIn, setActive, isLoaded } = useSignIn();
const [emailAddress, setEmailAddress] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const onSignInPress = async () => {
if (!isLoaded) return;
setIsLoading(true);
try {
const completeSignIn = await signIn.create({
identifier: emailAddress,
password,
});
await setActive({ session: completeSignIn.createdSessionId });
} catch (err: any) {
Alert.alert('Error', err.errors?.[0]?.message || 'Error al iniciar sesión');
} finally {
setIsLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.scrollContainer}>
<View style={styles.formContainer}>
<Text style={styles.title}>Iniciar Sesión</Text>
<TextInput
style={styles.input}
placeholder="Email"
value={emailAddress}
onChangeText={setEmailAddress}
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
testID="email-input"
/>
<TextInput
style={styles.input}
placeholder="Contraseña"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
testID="password-input"
/>
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={onSignInPress}
disabled={isLoading}
testID="signin-button"
>
<Text style={styles.buttonText}>
{isLoading ? 'Iniciando sesión...' : 'Iniciar Sesión'}
</Text>
</TouchableOpacity>
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>O continúa con</Text>
<View style={styles.dividerLine} />
</View>
<OAuthButtons />
</View>
</ScrollView>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
scrollContainer: {
flexGrow: 1,
justifyContent: 'center',
padding: 20,
},
formContainer: {
width: '100%',
maxWidth: 400,
alignSelf: 'center',
},
title: {
fontSize: 28,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 30,
color: '#333',
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 15,
marginBottom: 15,
fontSize: 16,
backgroundColor: '#f9f9f9',
},
button: {
backgroundColor: '#007AFF',
borderRadius: 8,
padding: 15,
alignItems: 'center',
marginBottom: 20,
},
buttonDisabled: {
backgroundColor: '#ccc',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 20,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: '#ddd',
},
dividerText: {
marginHorizontal: 10,
color: '#666',
fontSize: 14,
},
});
📝 Componente SignUp
// src/components/auth/SignUpScreen.tsx
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { useSignUp } from '@clerk/clerk-expo';
import { OAuthButtons } from './OAuthButtons';
export const SignUpScreen: React.FC = () => {
const { isLoaded, signUp, setActive } = useSignUp();
const [emailAddress, setEmailAddress] = useState('');
const [password, setPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [pendingVerification, setPendingVerification] = useState(false);
const [code, setCode] = useState('');
const onSignUpPress = async () => {
if (!isLoaded) return;
setIsLoading(true);
try {
await signUp.create({
emailAddress,
password,
firstName,
lastName,
});
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' });
setPendingVerification(true);
} catch (err: any) {
Alert.alert('Error', err.errors?.[0]?.message || 'Error al registrarse');
} finally {
setIsLoading(false);
}
};
const onPressVerify = async () => {
if (!isLoaded) return;
setIsLoading(true);
try {
const completeSignUp = await signUp.attemptEmailAddressVerification({
code,
});
await setActive({ session: completeSignUp.createdSessionId });
} catch (err: any) {
Alert.alert('Error', err.errors?.[0]?.message || 'Código incorrecto');
} finally {
setIsLoading(false);
}
};
if (pendingVerification) {
return (
<View style={styles.container}>
<View style={styles.formContainer}>
<Text style={styles.title}>Verificar Email</Text>
<Text style={styles.subtitle}>
Hemos enviado un código de verificación a {emailAddress}
</Text>
<TextInput
style={styles.input}
placeholder="Código de verificación"
value={code}
onChangeText={setCode}
keyboardType="number-pad"
testID="verification-code-input"
/>
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={onPressVerify}
disabled={isLoading}
testID="verify-button"
>
<Text style={styles.buttonText}>
{isLoading ? 'Verificando...' : 'Verificar'}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.scrollContainer}>
<View style={styles.formContainer}>
<Text style={styles.title}>Crear Cuenta</Text>
<TextInput
style={styles.input}
placeholder="Nombre"
value={firstName}
onChangeText={setFirstName}
autoCapitalize="words"
testID="firstname-input"
/>
<TextInput
style={styles.input}
placeholder="Apellido"
value={lastName}
onChangeText={setLastName}
autoCapitalize="words"
testID="lastname-input"
/>
<TextInput
style={styles.input}
placeholder="Email"
value={emailAddress}
onChangeText={setEmailAddress}
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
testID="email-input"
/>
<TextInput
style={styles.input}
placeholder="Contraseña"
value={password}
onChangeText={setPassword}
secureTextEntry
testID="password-input"
/>
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={onSignUpPress}
disabled={isLoading}
testID="signup-button"
>
<Text style={styles.buttonText}>
{isLoading ? 'Creando cuenta...' : 'Crear Cuenta'}
</Text>
</TouchableOpacity>
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>O continúa con</Text>
<View style={styles.dividerLine} />
</View>
<OAuthButtons />
</View>
</ScrollView>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
scrollContainer: {
flexGrow: 1,
justifyContent: 'center',
padding: 20,
},
formContainer: {
width: '100%',
maxWidth: 400,
alignSelf: 'center',
},
title: {
fontSize: 28,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 10,
color: '#333',
},
subtitle: {
fontSize: 16,
textAlign: 'center',
marginBottom: 30,
color: '#666',
lineHeight: 22,
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 15,
marginBottom: 15,
fontSize: 16,
backgroundColor: '#f9f9f9',
},
button: {
backgroundColor: '#007AFF',
borderRadius: 8,
padding: 15,
alignItems: 'center',
marginBottom: 20,
},
buttonDisabled: {
backgroundColor: '#ccc',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 20,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: '#ddd',
},
dividerText: {
marginHorizontal: 10,
color: '#666',
fontSize: 14,
},
});
🔗 Componente OAuth
// src/components/auth/OAuthButtons.tsx
import React from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Platform,
Alert,
} from 'react-native';
import { useOAuth } from '@clerk/clerk-expo';
import { useWarmUpBrowser } from '../hooks/useWarmUpBrowser';
export const OAuthButtons: React.FC = () => {
useWarmUpBrowser();
const { startOAuthFlow: googleOAuth } = useOAuth({ strategy: 'oauth_google' });
const { startOAuthFlow: appleOAuth } = useOAuth({ strategy: 'oauth_apple' });
const onGooglePress = async () => {
try {
const { createdSessionId, setActive } = await googleOAuth();
if (createdSessionId) {
setActive!({ session: createdSessionId });
}
} catch (err: any) {
Alert.alert('Error', 'Error al iniciar sesión con Google');
console.error('OAuth error', err);
}
};
const onApplePress = async () => {
try {
const { createdSessionId, setActive } = await appleOAuth();
if (createdSessionId) {
setActive!({ session: createdSessionId });
}
} catch (err: any) {
Alert.alert('Error', 'Error al iniciar sesión con Apple');
console.error('OAuth error', err);
}
};
return (
<View style={styles.container}>
<TouchableOpacity
style={[styles.oauthButton, styles.googleButton]}
onPress={onGooglePress}
testID="google-oauth-button"
>
<Text style={styles.googleButtonText}>Continuar con Google</Text>
</TouchableOpacity>
{Platform.OS === 'ios' && (
<TouchableOpacity
style={[styles.oauthButton, styles.appleButton]}
onPress={onApplePress}
testID="apple-oauth-button"
>
<Text style={styles.appleButtonText}>Continuar con Apple</Text>
</TouchableOpacity>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 12,
},
oauthButton: {
borderRadius: 8,
padding: 15,
alignItems: 'center',
borderWidth: 1,
},
googleButton: {
backgroundColor: '#fff',
borderColor: '#ddd',
},
appleButton: {
backgroundColor: '#000',
borderColor: '#000',
},
googleButtonText: {
color: '#333',
fontSize: 16,
fontWeight: '600',
},
appleButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
🔥 Hook para Optimización OAuth
// src/hooks/useWarmUpBrowser.ts
import { useEffect } from 'react';
import * as WebBrowser from 'expo-web-browser';
export const useWarmUpBrowser = () => {
useEffect(() => {
// Warm up the android browser to improve UX
// https://docs.expo.dev/guides/authentication/#improving-user-experience
void WebBrowser.warmUpAsync();
return () => {
void WebBrowser.coolDownAsync();
};
}, []);
};
🛡️ Componentes de Protección de Rutas
// src/components/auth/AuthGuard.tsx
import React from 'react';
import { useAuth } from '../../hooks/useAuth';
import { SignedIn, SignedOut } from '@clerk/clerk-expo';
import { AuthNavigator } from '../../navigation/AuthNavigator';
interface AuthGuardProps {
children: React.ReactNode;
}
export const AuthGuard: React.FC<AuthGuardProps> = ({ children }) => {
return (
<>
<SignedIn>{children}</SignedIn>
<SignedOut>
<AuthNavigator />
</SignedOut>
</>
);
};
🧭 Navegación de Autenticación
// src/navigation/AuthNavigator.tsx
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { SignInScreen } from '../components/auth/SignInScreen';
import { SignUpScreen } from '../components/auth/SignUpScreen';
export type AuthStackParamList = {
SignIn: undefined;
SignUp: undefined;
};
const Stack = createStackNavigator<AuthStackParamList>();
export const AuthNavigator: React.FC = () => {
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="SignIn" component={SignInScreen} />
<Stack.Screen name="SignUp" component={SignUpScreen} />
</Stack.Navigator>
);
};
🧪 Testing de Componentes de Autenticación
// src/components/auth/__tests__/SignInScreen.test.tsx
import React from 'react';
import { fireEvent, waitFor } from '@testing-library/react-native';
import { Alert } from 'react-native';
import { SignInScreen } from '../SignInScreen';
import { useSignIn } from '@clerk/clerk-expo';
import { render } from '../../../utils/test-utils';
jest.mock('@clerk/clerk-expo');
jest.spyOn(Alert, 'alert');
const mockUseSignIn = useSignIn as jest.MockedFunction<typeof useSignIn>;
describe('SignInScreen', () => {
const mockSignIn = {
create: jest.fn(),
};
const mockSetActive = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockUseSignIn.mockReturnValue({
signIn: mockSignIn,
setActive: mockSetActive,
isLoaded: true,
});
});
it('should render signin form correctly', () => {
const { getByTestId, getByText } = render(<SignInScreen />);
expect(getByText('Iniciar Sesión')).toBeTruthy();
expect(getByTestId('email-input')).toBeTruthy();
expect(getByTestId('password-input')).toBeTruthy();
expect(getByTestId('signin-button')).toBeTruthy();
});
it('should handle successful signin', async () => {
const mockSession = { createdSessionId: 'session_123' };
mockSignIn.create.mockResolvedValue(mockSession);
const { getByTestId } = render(<SignInScreen />);
fireEvent.changeText(getByTestId('email-input'), '[email protected]');
fireEvent.changeText(getByTestId('password-input'), 'password123');
fireEvent.press(getByTestId('signin-button'));
await waitFor(() => {
expect(mockSignIn.create).toHaveBeenCalledWith({
identifier: '[email protected]',
password: 'password123',
});
expect(mockSetActive).toHaveBeenCalledWith({
session: 'session_123',
});
});
});
it('should handle signin error', async () => {
const error = {
errors: [{ message: 'Invalid credentials' }],
};
mockSignIn.create.mockRejectedValue(error);
const { getByTestId } = render(<SignInScreen />);
fireEvent.changeText(getByTestId('email-input'), '[email protected]');
fireEvent.changeText(getByTestId('password-input'), 'wrongpassword');
fireEvent.press(getByTestId('signin-button'));
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith('Error', 'Invalid credentials');
});
});
it('should disable button while loading', async () => {
mockSignIn.create.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100))
);
const { getByTestId } = render(<SignInScreen />);
fireEvent.press(getByTestId('signin-button'));
expect(getByTestId('signin-button')).toBeDisabled();
});
});
🧪 Testing OAuth
// src/components/auth/__tests__/OAuthButtons.test.tsx
import React from 'react';
import { Platform } from 'react-native';
import { fireEvent, waitFor } from '@testing-library/react-native';
import { OAuthButtons } from '../OAuthButtons';
import { useOAuth } from '@clerk/clerk-expo';
import { render } from '../../../utils/test-utils';
jest.mock('@clerk/clerk-expo');
jest.mock('../hooks/useWarmUpBrowser');
const mockUseOAuth = useOAuth as jest.MockedFunction<typeof useOAuth>;
describe('OAuthButtons', () => {
const mockStartOAuthFlow = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockUseOAuth.mockReturnValue({
startOAuthFlow: mockStartOAuthFlow,
});
});
it('should render Google OAuth button', () => {
const { getByTestId } = render(<OAuthButtons />);
expect(getByTestId('google-oauth-button')).toBeTruthy();
});
it('should render Apple OAuth button on iOS', () => {
Platform.OS = 'ios';
const { getByTestId } = render(<OAuthButtons />);
expect(getByTestId('apple-oauth-button')).toBeTruthy();
});
it('should not render Apple OAuth button on Android', () => {
Platform.OS = 'android';
const { queryByTestId } = render(<OAuthButtons />);
expect(queryByTestId('apple-oauth-button')).toBeNull();
});
it('should handle Google OAuth flow', async () => {
const mockSetActive = jest.fn();
mockStartOAuthFlow.mockResolvedValue({
createdSessionId: 'session_123',
setActive: mockSetActive,
});
const { getByTestId } = render(<OAuthButtons />);
fireEvent.press(getByTestId('google-oauth-button'));
await waitFor(() => {
expect(mockStartOAuthFlow).toHaveBeenCalled();
expect(mockSetActive).toHaveBeenCalledWith({
session: 'session_123',
});
});
});
});
🔧 Actualización del Navegador Principal
// src/navigation/MainNavigator.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { AuthGuard } from '../components/auth/AuthGuard';
import { AppNavigator } from './AppNavigator';
export const MainNavigator: React.FC = () => {
return (
<NavigationContainer>
<AuthGuard>
<AppNavigator />
</AuthGuard>
</NavigationContainer>
);
};
📱 Pantalla de Perfil de Usuario
// src/components/UserProfile.tsx
import React from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Alert,
} from 'react-native';
import { useAuth } from '../hooks/useAuth';
export const UserProfile: React.FC = () => {
const { user, logout } = useAuth();
const handleLogout = () => {
Alert.alert(
'Cerrar Sesión',
'¿Estás seguro que deseas cerrar sesión?',
[
{ text: 'Cancelar', style: 'cancel' },
{ text: 'Cerrar Sesión', onPress: logout },
]
);
};
return (
<View style={styles.container}>
<Text style={styles.title}>Perfil</Text>
{user && (
<View style={styles.userInfo}>
<Text style={styles.label}>Nombre:</Text>
<Text style={styles.value}>
{user.firstName} {user.lastName}
</Text>
<Text style={styles.label}>Email:</Text>
<Text style={styles.value}>
{user.emailAddresses[0]?.emailAddress}
</Text>
</View>
)}
<TouchableOpacity
style={styles.logoutButton}
onPress={handleLogout}
testID="logout-button"
>
<Text style={styles.logoutButtonText}>Cerrar Sesión</Text>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
backgroundColor: '#fff',
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 30,
textAlign: 'center',
},
userInfo: {
marginBottom: 40,
},
label: {
fontSize: 16,
fontWeight: '600',
color: '#333',
marginTop: 15,
marginBottom: 5,
},
value: {
fontSize: 16,
color: '#666',
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
logoutButton: {
backgroundColor: '#FF3B30',
borderRadius: 8,
padding: 15,
alignItems: 'center',
},
logoutButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
🚀 Siguientes Pasos
En el próximo capítulo implementaremos:
- Gestión completa de perfil de usuario
- Configuraciones de cuenta
- Cambio de contraseña
- Gestión de métodos de autenticación
- Integración con el store de tareas
🎯 Resumen del Capítulo
Hemos implementado un sistema completo de autenticación:
✅ Componentes de SignIn y SignUp ✅ OAuth con Google y Apple ✅ Verificación de email ✅ Protección de rutas con AuthGuard ✅ Testing completo de flujos ✅ Navegación de autenticación ✅ Perfil de usuario básico
Próximo capítulo: Gestión de Usuario y Sesiones - Profundizaremos en la gestión del perfil de usuario y configuraciones avanzadas.