UTA DevHub
Guides

Social Authentication

Implementing Google, Facebook, and Apple Sign-In

GUIDE-07: Social Authentication Implementation Guide

Overview

This guide provides practical patterns for integrating third-party social logins (Google, Facebook, Apple) into your React Native application, ensuring alignment with our core architecture.

It leverages:

  • expo-auth-session for web-based OAuth flows (Google, Facebook).
  • expo-apple-authentication for native Apple Sign-In.
  • A dedicated socialLogin method in authService (see GUIDE-05).
  • A TanStack Query mutation (useSocialLoginMutation) to handle the backend verification step.
  • Standard updates to Zustand store and Secure Storage upon successful login.

The expected outcome is a seamless login experience for users preferring social providers, integrated cleanly into the existing authentication state management.

When To Use

Apply these patterns when:

  • You want to offer alternative login methods besides email/password.
  • You aim to reduce registration friction by using existing social profiles.
  • Your target audience frequently uses Google, Facebook, or Apple accounts.

Prerequisites:

  • Core Authentication Flow (GUIDE-05) is implemented.
  • Required Expo modules are installed: expo-auth-session, expo-crypto, expo-web-browser, expo-apple-authentication.
  • Backend endpoint for social login verification (/auth/social-login) is available and returns the standard LoginResponse.
  • OAuth Client IDs/Secrets and App configurations are completed on Google Cloud Console, Facebook Developer Portal, and Apple Developer Portal.
  • Client IDs and configuration are securely stored and accessible (e.g., via expo-constants).

Alternatives:

  • Email/Password Login (GUIDE-05)
  • Passwordless flows (Magic Links, OTP) - requires separate implementation.

Implementation Patterns

Process Flow Diagram (Google/Facebook Example)

1. Extend authService

(Ensure this is present as per GUIDE-05)

Confirm the existence of a method for backend social login verification.

// src/core/api/services/authService.ts
import { apiClient } from '../apiClient';
import { API_ENDPOINTS } from '../constants';
import type {
  LoginResponse, // Reuse the same response as standard login
} from '../types/authTypes';
 
// Interface for the social login payload to the backend
interface SocialLoginRequest {
  provider: 'google' | 'facebook' | 'apple';
  token: string; // Access token (Google/FB) or Identity token (Apple)
  userData?: { // Optional: Extra data for Apple sign-up
    email?: string | null;
    fullName?: {
        givenName?: string | null;
        familyName?: string | null;
    } | null;
  };
}
 
export const authService = {
  // ... existing login, register, refreshToken methods ...
 
  socialLogin: async (payload: SocialLoginRequest): Promise<LoginResponse> => {
    const { data } = await apiClient.post(API_ENDPOINTS.SOCIAL_LOGIN, payload);
    // Expects backend to return { user, token, refreshToken }
    return data;
  },
 
  // ... other methods ...
};

2. Create useSocialLoginMutation

This central mutation handles the state update logic after successful backend verification.

// src/features/auth/hooks/useSocialLoginMutation.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authService, SocialLoginRequest } from '@/core/api/services/authService';
import { secureStorage } from '@/core/security/storage/secureStorage';
import { apiClient } from '@/core/api/apiClient';
import { queryKeys } from '@/core/query/queryKeys';
import { useAuthStore } from '../state/useAuthStore';
// Import Alert or your preferred UI feedback mechanism
import { Alert } from 'react-native';
 
export const useSocialLoginMutation = () => {
  const queryClient = useQueryClient();
  const setTokens = useAuthStore((state) => state.setTokens);
  const setUser = useAuthStore((state) => state.setUser);
 
  return useMutation({
    mutationFn: (payload: SocialLoginRequest) => authService.socialLogin(payload),
    onSuccess: async (data) => {
      // 1. Update client state (Zustand)
      setTokens({ token: data.token, refreshToken: data.refreshToken });
      setUser(data.user);
      apiClient.setAuthHeader(data.token);
      await secureStorage.setItem('auth_token', data.token);
      await secureStorage.setItem('refresh_token', data.refreshToken);
      queryClient.setQueryData(queryKeys.users.current(), data.user);
    },
    onError: (error: any) => {
      console.error('Social login mutation failed:', error);
      // Provide user feedback for backend errors
      const message = error?.response?.data?.message || 'Social login failed. Please try again.';
      Alert.alert('Login Error', message);
    },
  });
};

3. Implement Provider-Specific Hooks

These hooks encapsulate the frontend interaction with each provider's SDK.

Google Sign-In (expo-auth-session)

// src/features/auth/hooks/useGoogleAuth.ts
import * as React from 'react';
import * as WebBrowser from 'expo-web-browser';
import * as Google from 'expo-auth-session/providers/google';
import Constants from 'expo-constants';
import { useSocialLoginMutation } from './useSocialLoginMutation';
import { Alert } from 'react-native';
 
WebBrowser.maybeCompleteAuthSession();
 
export const useGoogleAuth = () => {
  const socialLoginMutation = useSocialLoginMutation();
 
  const [request, response, promptAsync] = Google.useAuthRequest({
    expoClientId: Constants.expoConfig?.extra?.googleExpoClientId,
    iosClientId: Constants.expoConfig?.extra?.googleIosClientId,
    androidClientId: Constants.expoConfig?.extra?.googleAndroidClientId,
    webClientId: Constants.expoConfig?.extra?.googleWebClientId,
  });
 
  React.useEffect(() => {
    if (response?.type === 'success') {
      const { authentication } = response;
      if (authentication?.accessToken) {
        socialLoginMutation.mutate({
          provider: 'google',
          token: authentication.accessToken,
        });
      } else {
        console.error('Google Auth Success: No access token received');
        Alert.alert('Login Error', 'Could not get Google access token. Please try again.');
      }
    } else if (response?.type === 'error') {
      console.error('Google Auth Error:', response.error);
      Alert.alert('Login Error', 'Google authentication failed. Please try again.');
    } else if (response?.type === 'cancel') {
      console.log('Google Auth Cancelled by user');
    }
  }, [response, socialLoginMutation]);
 
  const signIn = React.useCallback(async () => {
    if (!request) {
      Alert.alert('Setup Issue', 'Google Auth configuration not ready.');
      return;
    }
    try {
      await promptAsync();
    } catch (error) {
      console.error('Error triggering Google promptAsync:', error);
      Alert.alert('Error', 'Could not start Google Sign-In. Please check your connection.');
    }
  }, [promptAsync, request]);
 
  return {
    signIn,
    isLoading: socialLoginMutation.isLoading || !request,
    error: socialLoginMutation.error, // Expose error from the mutation
  };
};

Facebook Sign-In (expo-auth-session)

// src/features/auth/hooks/useFacebookAuth.ts
import * as React from 'react';
import * as WebBrowser from 'expo-web-browser';
import * as Facebook from 'expo-auth-session/providers/facebook';
import Constants from 'expo-constants';
import { useSocialLoginMutation } from './useSocialLoginMutation';
import { Alert } from 'react-native';
 
WebBrowser.maybeCompleteAuthSession();
 
export const useFacebookAuth = () => {
  const socialLoginMutation = useSocialLoginMutation();
 
  const [request, response, promptAsync] = Facebook.useAuthRequest({
    clientId: Constants.expoConfig?.extra?.facebookAppId,
  });
 
  React.useEffect(() => {
    if (response?.type === 'success') {
      const { authentication } = response;
      if (authentication?.accessToken) {
        socialLoginMutation.mutate({
          provider: 'facebook',
          token: authentication.accessToken,
        });
      } else {
        console.error('Facebook Auth Success: No access token received');
        Alert.alert('Login Error', 'Could not get Facebook access token. Please try again.');
      }
    } else if (response?.type === 'error') {
      console.error('Facebook Auth Error:', response.error);
       Alert.alert('Login Error', 'Facebook authentication failed. Please try again.');
    } else if (response?.type === 'cancel') {
      console.log('Facebook Auth Cancelled by user');
    }
  }, [response, socialLoginMutation]);
 
  const signIn = React.useCallback(async () => {
    if (!request) {
        Alert.alert('Setup Issue', 'Facebook Auth configuration not ready.');
        return;
    }
    try {
      await promptAsync();
    } catch(error) {
      console.error('Error triggering Facebook promptAsync:', error);
      Alert.alert('Error', 'Could not start Facebook Sign-In. Please check your connection.');
    }
  }, [promptAsync, request]);
 
  return {
    signIn,
    isLoading: socialLoginMutation.isLoading || !request,
    error: socialLoginMutation.error,
  };
};

Apple Sign-In (expo-apple-authentication)

// src/features/auth/hooks/useAppleAuth.ts
import * as React from 'react';
import * as AppleAuthentication from 'expo-apple-authentication';
import { Platform, Alert } from 'react-native';
import { useSocialLoginMutation } from './useSocialLoginMutation';
 
export const useAppleAuth = () => {
  const socialLoginMutation = useSocialLoginMutation();
  const [isAvailable, setIsAvailable] = React.useState(false);
 
  React.useEffect(() => {
    if (Platform.OS === 'ios') {
      AppleAuthentication.isAvailableAsync().then(setIsAvailable);
    }
  }, []);
 
  const signIn = React.useCallback(async () => {
    if (!isAvailable) {
      Alert.alert('Not Available', 'Apple Sign-In is not available on this device.');
      return;
    }
 
    try {
      const credential = await AppleAuthentication.signInAsync({
        requestedScopes: [
          AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
          AppleAuthentication.AppleAuthenticationScope.EMAIL,
        ],
      });
 
      if (credential.identityToken) {
        socialLoginMutation.mutate({
          provider: 'apple',
          token: credential.identityToken,
          userData: {
              email: credential.email,
              fullName: credential.fullName,
          }
        });
      } else {
        console.error('Apple Sign-In Success: No identity token received.');
        Alert.alert('Login Error', 'Could not get Apple identity token. Please try again.');
      }
 
    } catch (error: any) {
      if (error.code === 'ERR_REQUEST_CANCELED') {
        console.log('Apple Sign-In Cancelled by user.');
      } else {
        console.error('Apple Sign-In Error:', error);
        Alert.alert('Login Error', 'Apple Sign-In failed. Please try again.');
      }
    }
  }, [isAvailable, socialLoginMutation]);
 
  return {
    isAvailable,
    signIn,
    isLoading: socialLoginMutation.isLoading,
    error: socialLoginMutation.error,
  };
};

Examples

1. Standalone SocialAuthButtons Component

This component groups the social login buttons and can be imported into other screens.

// src/features/auth/components/SocialAuthButtons/SocialAuthButtons.tsx
import React from 'react';
import { View, TouchableOpacity, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { useGoogleAuth } from '../../hooks/useGoogleAuth';
import { useFacebookAuth } from '../../hooks/useFacebookAuth';
import { useAppleAuth } from '../../hooks/useAppleAuth';
// Import icons if you have them
// import { GoogleIcon, FacebookIcon, AppleIcon } from '@/ui/icons';
 
export const SocialAuthButtons = () => {
  const { signIn: googleSignIn, isLoading: isGoogleLoading } = useGoogleAuth();
  const { signIn: facebookSignIn, isLoading: isFacebookLoading } = useFacebookAuth();
  const { signIn: appleSignIn, isLoading: isAppleLoading, isAvailable: isAppleAvailable } = useAppleAuth();
 
  // Unified loading state to disable all buttons during any social auth attempt
  const isLoading = isGoogleLoading || isFacebookLoading || isAppleLoading;
 
  return (
    <View style={styles.container}>
      {/* Google Button */}
      <TouchableOpacity
        style={[styles.button, styles.googleButton, isLoading && styles.buttonDisabled]}
        onPress={googleSignIn}
        disabled={isLoading}
      >
        {isGoogleLoading ? (
          <ActivityIndicator color="#fff" />
        ) : (
          // Replace Text with <GoogleIcon ... /> if available
          <Text style={styles.buttonText}>Sign in with Google</Text>
        )}
      </TouchableOpacity>
 
      {/* Facebook Button */}
      <TouchableOpacity
        style={[styles.button, styles.facebookButton, isLoading && styles.buttonDisabled]}
        onPress={facebookSignIn}
        disabled={isLoading}
      >
        {isFacebookLoading ? (
          <ActivityIndicator color="#fff" />
        ) : (
           // Replace Text with <FacebookIcon ... /> if available
          <Text style={styles.buttonText}>Sign in with Facebook</Text>
        )}
      </TouchableOpacity>
 
      {/* Apple Button (Conditionally Rendered) */}
      {isAppleAvailable && (
        <TouchableOpacity
          style={[styles.button, styles.appleButton, isLoading && styles.buttonDisabled]}
          onPress={appleSignIn}
          disabled={isLoading}
        >
          {isAppleLoading ? (
            <ActivityIndicator color="#fff" />
          ) : (
            // Replace Text with <AppleIcon ... /> if available
            <Text style={styles.buttonText}>Sign in with Apple</Text>
          )}
        </TouchableOpacity>
      )}
    </View>
  );
};
 
// Add appropriate styles
const styles = StyleSheet.create({
  container: { /* ... */ width: '100%', alignItems: 'center' },
  button: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    paddingVertical: 12,
    paddingHorizontal: 20,
    borderRadius: 8,
    width: '80%',
    marginBottom: 15,
    minHeight: 48, // Ensure consistent button height
  },
  buttonDisabled: {
      opacity: 0.6,
  },
  googleButton: { backgroundColor: '#DB4437' },
  facebookButton: { backgroundColor: '#4267B2' },
  appleButton: { backgroundColor: '#000000' },
  buttonText: { color: '#fff', marginLeft: 10, fontWeight: '500', fontSize: 16 },
});

2. Integration into Login/Register Screen

Import and use the SocialAuthButtons component within your existing screens.

// src/features/auth/screens/LoginScreen/LoginScreen.tsx
import React from 'react';
import { View, Text, StyleSheet /* ... other imports */ } from 'react-native';
import { useAuth } from '@/features/auth/hooks/useAuth';
import { SocialAuthButtons } from '../../components/SocialAuthButtons/SocialAuthButtons';
// Assuming Formik, Button, TextInput etc. are imported
 
export const LoginScreen = ({ navigation }) => {
  const { isLoggingIn } = useAuth(); // Status from standard login
 
  // ... handleLoginSubmit using useAuth().login ...
 
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Welcome Back</Text>
 
      {/* ... Email/Password Formik Form ... */}
      {/* Disable form submit button if isLoggingIn is true */}
 
      <View style={styles.dividerContainer}>
        <View style={styles.divider} />
        <Text style={styles.dividerText}>OR</Text>
        <View style={styles.divider} />
      </View>
 
      {/* Add the social buttons component */}
      {/* The component handles its own loading state internally */}
      <SocialAuthButtons />
 
      {/* ... Footer ... */}
    </View>
  );
};
 
// Add necessary styles
const styles = StyleSheet.create({
    container: { /* ... */ },
    title: { /* ... */ },
    dividerContainer: { flexDirection: 'row', alignItems: 'center', marginVertical: 20 },
    divider: { flex: 1, height: 1, backgroundColor: '#ccc' },
    dividerText: { marginHorizontal: 10, color: '#888' },
    // ... other styles
});

Common Challenges

  1. Configuration Errors: Double-check client IDs, bundle identifiers (iOS), package names/key hashes (Android), and authorized redirect URIs in each provider's developer console. Mismatches are common causes of errors, often resulting in provider-specific error messages during the promptAsync step.
  2. Backend Verification Logic: The backend (/auth/social-login) must securely verify the incoming token with the provider before trusting it (e.g., using Google's tokeninfo endpoint, Facebook's Debug Token API, or validating the Apple JWT signature and claims). Failing to do this is a major security vulnerability.
  3. User Account Merging: Define a clear backend strategy for linking social profiles to existing email/password accounts. Common approaches include automatic linking if emails match or prompting the user if a conflict occurs.
  4. Expo Go Limitations: Social logins often rely on native configurations (URL schemes, entitlements) that may not work correctly in the Expo Go app. Testing in development builds (npx expo run:ios/android) or release builds is recommended.
  5. Platform Availability: Apple Sign-In is iOS-only. Use the isAvailable flag from the hook to conditionally render the Apple button.
  6. Token Handling: Ensure the correct token (accessToken for Google/Facebook, identityToken for Apple) is extracted from the provider response and sent to your backend.
  7. Error Feedback: Provide clear user feedback if the social login process fails at any stage (provider interaction, network error, backend verification failure).

Performance Considerations

  • Network Latency: Social logins involve multiple network round trips (app <-> Expo Auth Service <-> Social Provider <-> Expo Auth Service <-> App <-> Your Backend <-> Social Provider <-> Your Backend). This inherently takes longer than a direct email/password check.
  • Provider Availability: Downtime or issues with the social provider's authentication service are outside your control but will impact the user experience.
  • Backend Verification Time: The speed of your /auth/social-login endpoint in verifying the token and looking up/creating the user significantly affects the overall login time.

Optimization is generally focused on providing clear loading states in the UI rather than speeding up the external dependencies.

Security Considerations

  • (Do ✅) ALWAYS verify the social provider token on your backend before issuing your application's session tokens. This is the most critical security step.
  • (Do ✅) Use the state parameter in OAuth flows (expo-auth-session handles this automatically) to prevent CSRF attacks.
  • (Don't ❌) Store provider access tokens long-term unless absolutely necessary for specific API calls; rely on your application's own session tokens.
  • (Do ✅) Handle user data (like name and email) obtained from social providers according to your privacy policy and user consent.
  • (Be Aware ❗) Of the specific scopes you request (e.g., email, profile) and only ask for necessary permissions.
  • GUIDE-05: Authentication Flow Implementation Guide (Core Flow)
  • DOC-03: API & State Management Reference
  • DOC-04: Security & Offline Framework Reference