La autenticación social es una de las funcionalidades más requeridas en las aplicaciones móviles modernas. Permite a los usuarios registrarse e iniciar sesión con un solo toque utilizando sus cuentas existentes de Google, GitHub u otras plataformas. En esta sección, analizaremos cómo implementar el flujo completo de autenticación de forma segura combinando Flutter y un backend web.
Ofrecer esta característica en entornos móviles es fundamental para mantener la coherencia de todo el ecosistema de software. Si una plataforma ya permite la autenticación social en su versión web y cuenta además con una aplicación móvil, lo correcto es replicar este mecanismo en el dispositivo. De lo contrario, un usuario que haya creado su cuenta originalmente a través de un proveedor externo se verá imposibilitado para iniciar sesión en la aplicación móvil.
Antes vimos como generar un PDF en Flutter
Autenticación Social (Google)
Índice de contenido
- Autenticación Social (Google)
- Flujo de Trabajo General
- 1. Configuración de Google Cloud Console
- Cómo Obtener las Huellas SHA-1
- 2. Implementación en Flutter (Servicio en Dart)
- 3. Integración con el Backend
- Opción A: Servidor Laravel con Laravel Socialite
- Opción B: Proceso Manual en Backend (Python / Django)
- Autenticación con GitHub
- 1. La lógica en el Backend: Controladores y Base de Datos
- 2. Integración del Flujo de Autenticación en Flutter
- 3. Configuración de Credenciales y Captura del Código
- 4. Intercambio de Tokens y el Problema del Email Privado
- Resumen del Flujo General
- Migración para el usuario
Flujo de Trabajo General
El flujo recomendado para realizar autenticación social de manera segura en dispositivos móviles consta de los siguientes pasos:
- Autenticación Nativa/Web en el Dispositivo: La app móvil inicia el flujo correspondiente (nativo para Google o a través de un WebView controlado para plataformas como GitHub) para que el usuario inicie sesión.
- Obtención del Token: Una vez autenticado con el proveedor social, la app obtiene un token de acceso (Access Token) o de identidad (ID Token). Para mitigar problemas con estados persistentes en caché que fuercen inicios de sesión automáticos no deseados, es una excelente práctica ejecutar un cierre de sesión explícito en el cliente (
signOut) inmediatamente antes de disparar el nuevo flujo de autenticación. - Envío al Servidor (Backend): La app envía este token a la API del backend mediante HTTPS junto al nombre del proveedor. Es indispensable comprender que la autenticación social no debe gestionarse exclusivamente de manera local en el dispositivo móvil; los datos de sesión y del usuario deben persistirse e interactuar directamente con un backend (desarrollado en Laravel, Django, FastAPI o Node.js) para que la autenticación tenga un valor real y seguro dentro del sistema.
- Validación en el Servidor: El servidor contacta al proveedor para validar el token y obtener los datos del usuario de forma segura. Si es válido, busca o crea el usuario en la base de datos y genera el token de sesión definitivo (por ejemplo, con Laravel Sanctum).
- Respuesta y Persistencia: El backend retorna el token al dispositivo, el cual se guarda de manera segura para autenticar futuras peticiones. La información del usuario devuelta por la API debe ser idéntica a la que se entregaría en un flujo de autenticación tradicional mediante usuario y contraseña.
Este es el prompt que usé:
Actúa como un desarrollador Senior de Flutter y arquitectura móvil.
Necesito que crees el flujo completo de autenticación social (Google y GitHub) en mi aplicación Flutter. El backend ya está desarrollado en Laravel (Sanctum) y espera recibir los tokens nativos del teléfono.
A continuación, te proporciono el archivo de rutas y el controlador exacto de mi API en Laravel para que entiendas cómo debe comunicarse la app móvil con el servidor:
---
[RUTAS DE LARAVEL (routes/api.php)]
Route::prefix('auth')->group(function () {
Route::post('/social', [SocialApiController::class, 'handleSocialAuth']);
Route::post('/social/register-email', [SocialApiController::class, 'storeSocialEmail']);
});[CONTROLADOR DE LARAVEL (SocialApiController.php)]
<?phpnamespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;class SocialController extends Controller
{
/**
* Autentica un usuario de la App Móvil usando un Token Social.
* * POST /api/auth/social
*/
public function handleSocialAuth(Request $request): JsonResponse
{
$request->validate([
'provider' => ['required', 'string', 'in:google,apple,github'], // los proveedores que soportes
'access_token' => ['required', 'string'], // El token que la App Móvil consiguió de forma nativa
]);$provider = $request->input('provider');
$token = $request->input('access_token');try {
// En lugar de ->user(), usamos ->userFromToken() para validar el token que viene de la App móvil
$socialUser = Socialite::driver($provider)->userFromToken($token);
} catch (\Exception $e) {
return response()->json([
'message' => 'No se pudo validar el token con el proveedor social.',
'error' => $e->getMessage()
], 401);
}// 1. Intentar buscar por ID Social
$user = User::where($provider . '_id', $socialUser->getId())->first();if ($user) {
return $this->generateAuthResponse($user, 'Login exitoso por ID social.');
}// 2. Si no hay ID, verificar por Email
$email = $socialUser->getEmail();if (!$email) {
// En API no podemos usar sesiones (State-less). Si la red social no da email,
// le respondemos a la app móvil con los datos para que ella pinte un formulario propio.
return response()->json([
'message' => 'Email requerido por el sistema.',
'requires_email' => true,
'social_data' => [
'id' => $socialUser->getId(),
'name' => $socialUser->getName() ?? $socialUser->getNickname() ?? 'User',
'avatar' => $socialUser->getAvatar(),
'provider' => $provider,
]
], 200); // Retornamos 200 pero indicando que falta el email en el flujo de la app
}$user = User::where('email', $email)->first();
if ($user) {
// Vincular cuenta existente
$user->update([
$provider . '_id' => $socialUser->getId(),
'avatar' => $user->avatar ?? $socialUser->getAvatar(),
]);
} else {
// Crear nuevo usuario desde la App Móvil
$user = User::create([
'name' => $socialUser->getName() ?? $socialUser->getNickname() ?? 'User',
'email' => $email,
$provider . '_id' => $socialUser->getId(),
'avatar' => $socialUser->getAvatar(),
'password' => Hash::make(Str::random(24)),
'email_verified_at' => now(), // Al venir de API móvil validada, asumimos verificación exitosa
]);// Sincroniza con tus listeners (ej. tu tabla de suscriptores)
event(new Registered($user));
event(new Verified($user));
}return $this->generateAuthResponse($user, 'Usuario autenticado correctamente.');
}/**
* Endpoint secundario por si la app tuvo que pedir el email manualmente en el teléfono.
* * POST /api/auth/social/register-email
*/
public function storeSocialEmail(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'provider' => ['required', 'string'],
'social_id' => ['required', 'string'],
'name' => ['required', 'string'],
'avatar' => ['nullable', 'string'],
]);$user = User::create([
'name' => $request->name,
'email' => $request->email,
$request->provider . '_id' => $request->social_id,
'avatar' => $request->avatar,
'password' => Hash::make(Str::random(24)),
'email_verified_at' => now(),
]);event(new Registered($user));
event(new Verified($user));return $this->generateAuthResponse($user, 'Usuario creado con email proveído de forma manual.');
}/**
* Genera la respuesta estándar de API con el Token de Sanctum.
*/
private function generateAuthResponse(User $user, string $message): JsonResponse
{
return response()->json([
'message' => $message,
'access_token' => getUserTokenAuth(user: $user),
'token_type' => 'Bearer',
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar' => $user->avatar,
]
], 200);
}
}
---Con base en este backend, genera el código de Flutter estructurado de forma limpia (Clean Architecture o separación por servicios/repositorios si es posible):
2. **GoogleDriveService / SocialAuthService:** Un servicio en Dart que maneje la lógica nativa:
- Iniciar sesión con Google nativo (`google_sign_in`) para obtener el `authentication.accessToken` o `idToken`.
- Hacer la petición POST a `https://academy.desarrollolibre.net/api/auth/social` enviando el `provider` ("google" o "github") y el `access_token`.
3. **Manejo del Flujo Especial (Requires Email):**
- Si la API de Laravel responde con un 200 y `requires_email: true`, el servicio debe ser capaz de capturar esos datos (`social_data`) y pasárselos a la interfaz para que la app le muestre un formulario al usuario y luego consuma el endpoint `/api/auth/social/register-email`.
4. **Persistencia del Token:** Al recibir con éxito el `access_token` de Laravel Sanctum, guárdalo de forma segura en el dispositivo para usarlo en los headers Bearer de las peticiones protegidas.
5. **Interfaz de Usuario (UI Básica con Tailwind/Flutter o Widgets limpios):** Genera una vista de Login sencilla con los dos botones ("Continuar con Google" y "Continuar con GitHub") que disparen el flujo.REGLAS DE CÓDIGO:
- Usa un manejo de errores robusto con bloques try/catch y muestra prints claros en consola si algo falla en la red.
- Adapta a provider adáptalo de forma simple, o estructúralo con funciones asíncronas en un StatefulWidget
- Devuelve el código limpio y listo para implementar.Finalmente, esta es la pagina de login:
lib/pages/academy/user/login_page.dart
Donde tienen que estar definidos los enlaces
1. Configuración de Google Cloud Console
Para implementar el inicio de sesión con Google, es indispensable configurar correctamente las credenciales en la Google Cloud Console. Uno de los errores más comunes es confundir los distintos tipos de credenciales necesarios:
- Credencial de Aplicación Web (Backend): Debes crear una credencial de tipo "Aplicación Web". El ID de cliente generado aquí (denominado
serverClientIden Flutter) se utiliza para indicarle al SDK de Google que necesitamos un token compatible para que el backend pueda validarlo. Si se omite este parámetro o se configura con las credenciales de Android, el servidor web será incapaz de comprobar la validez del token recibido. - Credencial de Android: Debes crear una credencial de tipo "Android". En esta credencial debes ingresar el nombre del paquete de tu app (por ejemplo,
com.your.app) y la huella digital SHA-1 de firma de tu aplicación. A nivel de código en Flutter no es necesario mapear explícitamente este ID de cliente de Android, ya que el ecosistema de Google realiza la validación de manera interna cruzando el nombre del paquete y la firma digital.
Cómo Obtener las Huellas SHA-1
Google exige la huella digital SHA-1 para autorizar las peticiones de inicio de sesión nativas desde Android.
SHA-1 para Pruebas (Debug):
En tu máquina de desarrollo, la aplicación se firma automáticamente con un almacén de claves por defecto. Puedes obtener esta firma ejecutando el siguiente comando en la terminal (la contraseña predeterminada del almacén es android):
$ keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass androidSHA-1 para Producción:
Si estás compilando un APK de producción en tu máquina mediante flutter build apk --release usando tu propio archivo Almacén de claves (.jks o .keystore), necesitas extraer la SHA-1 directamente de ese archivo privado. Ejecuta el siguiente comando en tu terminal (necesitas tener instalado Java/Keytool en tu sistema):
$ keytool -list -v -keystore /ruta/de/tu/archivo-llave.jks -alias tu-alias-de-produccionReemplaza /ruta/de/tu/archivo-llave.jks por la ubicación real de tu archivo de firmas. Te pedirá la contraseña que le asignaste a tu llave al crearla. En la salida verás el bloque de Huellas digitales con la SHA-1 de producción.
Nota importante sobre Producción y Google Play: Al subir la aplicación a producción, Google Play Console suele modificar la firma digital de la aplicación a través del servicio de protección de la Play Store. Esto significa que la SHA-1 generada localmente para release dejará de funcionar en producción. Para solucionarlo, debes ingresar a la consola de desarrollo de Google Play, seleccionar tu aplicación, navegar a la sección de Protección de la Play Store (Play App Signing), copiar la huella digital SHA-1 provista directamente por Google e ingresarla como un nuevo cliente Android dentro de tu proyecto en Google Cloud Console.
2. Implementación en Flutter (Servicio en Dart)
Para manejar la lógica de Google nativo se utiliza el paquete google_sign_in. A continuación se presenta una estructura limpia del servicio:
import 'dart:convert';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:http/http.dart' as http;
class SocialAuthService {
// Es indispensable configurar el ID de cliente correspondiente a la APLICACIÓN WEB
final GoogleSignIn _googleSignIn = GoogleSignIn(
serverClientId: "TU_CLIENT_ID_DE_APLICACION_WEB.apps.googleusercontent.com",
scopes: ['email', 'profile'],
);
Future<String?> getGoogleAccessToken() async {
try {
// Forzar el cierre de sesión previo para limpiar estados en caché y evitar logins automáticos
if (await _googleSignIn.isSignedIn()) {
await _googleSignIn.signOut();
}
final GoogleSignInAccount? account = await _googleSignIn.signIn();
if (account == null) return null; // Flujo cancelado por el usuario
final GoogleSignInAuthentication auth = await account.authentication;
// Se prioriza el accessToken o el idToken de acuerdo a los requerimientos de validación del backend
return auth.accessToken ?? auth.idToken;
} catch (e) {
print("Error en Google Sign-In: \$e");
return null;
}
}
}
La página de login de ejemplo:
lib\pages\user\login_page.dart
class _LoginPageState extends State<LoginPage> {
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final SocialAuthService _socialAuthService = SocialAuthService();
***
ElevatedButton(
text: "Google",
colors: [
const Color(0xFFEA4335),
const Color(0xFFDB4437)
],
onPressed: _loginWithGoogle,
),
***
void _loginWithGoogle() async {
setStateIfMounted(() {
socialLoading = true;
});
try {
final token = await _socialAuthService.getGoogleAccessToken();
if (token == null) {
setStateIfMounted(() {
socialLoading = false;
});
return;
}
final response = await _socialAuthService.authenticateOnBackend(
provider: 'google',
accessToken: token,
);
_handleSocialBackendResponse(response, 'google');
} catch (e) {
debugPrint("Error Google Auth flow: $e");
showToastMessage(context, LocaleKeys.errorGoogleLogin.tr());
setStateIfMounted(() {
socialLoading = false;
});
}
}Como puedes ver son dos pasos:
- getGoogleAccessToken, es 100% Google, para obtener el Token.
- authenticateOnBackend, es un método que gestiona la respuesta de tu Rest API que, dado el token de autorización de Google del paso uno, lo valida y devuelve TU usuario autenticado de TU app.
pubspec.yaml
google_sign_in:Future<Map<String, dynamic>> authenticateOnBackend({
required String provider,
required String accessToken,
}) async {
var url = Uri.https(baseUrlAPI, "/api/v1/auth/social");
if (!appApiUseHttps) {
url = Uri.http(baseUrlAPI, "/api/v1/auth/social");
}
try {
final response = await Api.post(
url,
body: {
'provider': provider,
'access_token': accessToken,
},
);
debugPrint("Response Status: ${response.statusCode}");
debugPrint("Response Body: ${response.body}");
if (response.statusCode == 200) {
return json.decode(response.body) as Map<String, dynamic>;
} else {
final Map<String, dynamic> errorData =
json.decode(response.body) as Map<String, dynamic>;
return {
'error': true,
'message':
errorData['message'] ?? 'Error de autenticación con el servidor.'
};
}
} catch (e) {
debugPrint("Error al conectar con el backend: $e");
return {
'error': true,
'message': 'No se pudo conectar con el servidor de Desarrollolibre.'
};
}
}3. Integración con el Backend
Opción A: Servidor Laravel con Laravel Socialite
Laravel Socialite simplifica la validación de tokens sociales mediante el método userFromToken(). A continuación, se muestra el controlador de Laravel que procesa la petición de la app móvil:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Laravel\Socialite\Facades\Socialite;
class SocialController extends Controller
{
public function handleSocialAuth(Request $request): JsonResponse
{
$request->validate([
'provider' => ['required', 'string', 'in:google,github'],
'access_token' => ['required', 'string'],
]);
$provider = $request->input('provider');
$token = $request->input('access_token');
// Para GitHub: si viene un código temporal (OAuth2 Web) en lugar del token final, lo intercambiamos
if ($provider === 'github' && !str_starts_with($token, 'gho_') && !str_starts_with($token, 'ghu_')) {
$response = \Illuminate\Support\Facades\Http::asJson()->post('https://github.com/login/oauth/access_token', [
'client_id' => config('services.github.client_id'),
'client_secret' => config('services.github.client_secret'),
'code' => $token,
]);
if ($response->successful()) {
$data = [];
parse_str($response->body(), $data);
$token = $data['access_token'] ?? $token;
}
}
try {
$socialUser = Socialite::driver($provider)->userFromToken($token);
} catch (\Exception $e) {
return response()->json(['message' => 'Token inválido', 'error' => $e->getMessage()], 401);
}
$user = User::where($provider . '_id', $socialUser->getId())->first();
if (!$user) {
$email = $socialUser->getEmail();
if (!$email) {
return response()->json([
'requires_email' => true,
'social_data' => [
'id' => $socialUser->getId(),
'name' => $socialUser->getName() ?? 'User',
'avatar' => $socialUser->getAvatar(),
'provider' => $provider,
]
], 200);
}
$user = User::create([
'name' => $socialUser->getName() ?? 'User',
'email' => $email,
$provider . '_id' => $socialUser->getId(),
'avatar' => $socialUser->getAvatar(),
'password' => bcrypt(str_random(24)),
]);
}
return response()->json([
'access_token' => $user->createToken('mobile-app')->plainTextToken,
'token_type' => 'Bearer',
'user' => $user
], 200);
}
}Este método es el empleado desde Flutter en authenticateOnBackend().
Opción B: Proceso Manual en Backend (Python / Django)
Si no utilizas Laravel, puedes validar el token manualmente realizando una solicitud HTTPS directa a las APIs de validación del proveedor. A continuación, se muestra un ejemplo conceptual en Python (Django / Flask) para verificar tokens de Google y GitHub:
import requests
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
import json
@csrf_exempt
def handle_social_auth(request):
if request.method == 'POST':
data = json.loads(request.body)
provider = data.get('provider')
token = data.get('access_token')
if provider == 'google':
# Validación de ID Token con la API de Google
response = requests.get(f'https://oauth2.googleapis.com/tokeninfo?id_token={token}')
if response.status_code != 200:
return JsonResponse({'message': 'Token de Google inválido'}, status=401)
user_info = response.json()
social_id = user_info.get('sub')
email = user_info.get('email')
name = user_info.get('name')
elif provider == 'github':
# Intercambio de código por access_token si aplica
if not token.startswith('gho_'):
res = requests.post(
'https://github.com/login/oauth/access_token',
headers={'Accept': 'application/json'},
data={
'client_id': 'TU_GITHUB_CLIENT_ID',
'client_secret': 'TU_GITHUB_CLIENT_SECRET',
'code': token
}
)
token = res.json().get('access_token')
# Consulta directa a la API de GitHub
headers = {'Authorization': f'token {token}'}
response = requests.get('https://api.github.com/user', headers=headers)
if response.status_code != 200:
return JsonResponse({'message': 'Token de GitHub inválido'}, status=401)
user_info = response.json()
social_id = str(user_info.get('id'))
name = user_info.get('name') or user_info.get('login')
email = user_info.get('email') # Puede requerir llamada adicional si es privado
# Aquí procedes a registrar o loguear al usuario en tu BD y emitir tu token JWT
return JsonResponse({'message': 'Autenticación exitosa', 'user': {'name': name, 'email': email}})Autenticación con GitHub
Vamos ahora con la autenticación mediante GitHub. He dividido esta sección en dos partes para facilitar su lectura. Te recomiendo revisar primero el apartado de la autenticación con Gmail, ya que existen partes del flujo general que se explicaron allí y que omitiremos en esta sección para evitar duplicidades.
1. La lógica en el Backend: Controladores y Base de Datos
Al igual que ocurre con Gmail, el objetivo principal es que el usuario autenticado quede registrado en el backend. Carece de sentido técnico autenticar exclusivamente la aplicación móvil sin persistir ni vincular la información provista por el proveedor social en nuestro propio servidor.
Con este tipo de login social, nos conectamos al backend mediante el ID único del proveedor para obtener los detalles del usuario desde nuestra base de datos. En este caso, utilizaremos Laravel junto con el paquete Laravel Socialite, el cual simplifica considerablemente todo el proceso. Si utilizas otros entornos como Python con Django, el flujo metodológico es exactamente el mismo, a excepción de un pequeño ajuste que ya dejamos implementado en el código anterior:
if ($provider === 'github' && !str_starts_with($token, 'gho_') && !str_starts_with($token, 'ghu_')) {El método para obtener los datos del usuario mediante el driver social indicando el proveedor (Google o GitHub) se mantiene idéntico:
$socialUser = Socialite::driver($provider)->userFromToken($token);En resumidas cuentas, el flujo consiste en buscar al usuario mediante el ID único que nos provee la red social. Para ello, es necesario modificar la tabla de usuarios en la base de datos e incorporar los campos correspondientes a los proveedores: github_id, google_id y el avatar. Puedes optar por unificar esto en un único campo provider_id junto a un atributo numerado (enum) para identificar si es Facebook, Google o GitHub. En este desarrollo, se maneja un campo específico por plataforma debido a que el público objetivo principal (desarrolladores) suele centralizar sus accesos en Google y GitHub.
Antes de profundizar en la gestión del token en el backend, es fundamental comprender cómo se comporta este flujo desde la aplicación móvil.
2. Integración del Flujo de Autenticación en Flutter
En la interfaz de la aplicación disponemos de un botón personalizado para GitHub que invoca el método interno _loginWithGitHub. A diferencia de Google, que cuenta con un paquete oficial nativo listo para usar, con GitHub debemos realizar un proceso manual.
La estrategia más eficiente y recomendada consiste en abrir una página web externa integrada (WebView) para que el usuario introduzca sus credenciales. A partir de ahí, capturamos un código de autorización intermedio, lo enviamos a nuestra REST API y el backend se encarga de realizar el intercambio final por el access token para culminar la autenticación.
A continuación, se detalla la implementación de este flujo en Flutter:
void _loginWithGitHub() async {
setStateIfMounted(() {
socialLoading = true;
});
try {
final code = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => const GitHubWebViewPage(
clientId: githubClientId,
redirectUri: githubRedirectUri,
),
),
);
if (code == null) {
setStateIfMounted(() {
socialLoading = false;
});
return;
}
// En el caso de GitHub, enviamos el authorization code al backend
// El backend Laravel Socialite usa ->userFromToken($token) el cual, para Github,
// espera recibir el Access Token final.
// O, si tu backend espera el authorization code directamente en 'access_token',
// puedes pasarlo aquí. Por convención habitual de Sanctum + Socialite Driver,
// a veces el driver de Socialite espera el access_token directo.
// Enviamos el código.
final response = await _socialAuthService.authenticateOnBackend(
provider: 'github',
accessToken: code,
);
_handleSocialBackendResponse(response, 'github');
} catch (e) {
debugPrint("Error GitHub Auth flow: $e");
showToastMessage(context, LocaleKeys.errorGithubLogin.tr());
setStateIfMounted(() {
socialLoading = false;
});
}
}
void _handleSocialBackendResponse(
Map<String, dynamic> response, String provider) {
setStateIfMounted(() {
socialLoading = false;
});
if (response['error'] == true) {
showToastMessage(
context, response['message'] ?? LocaleKeys.socialAuthError.tr());
return;
}
if (response['requires_email'] == true) {
// Flujo especial: no se proporcionó email por la red social
final socialData = response['social_data'] as Map<String, dynamic>;
_showEmailRegistrationDialog(socialData, provider);
} else {
// Exitoso
final email = response['user']?['email'] ?? '';
_completeUserLogin(response, email);
}
}
3. Configuración de Credenciales y Captura del Código
Para que el componente GitHubWebViewPage funcione, es necesario configurar previamente el Client ID y la Redirect URI correspondientes dentro de la plataforma de desarrollo de GitHub (Developer Settings -> OAuth Apps). La ventaja técnica de GitHub frente a Google Cloud Platform es que su configuración es más directa: al no depender directamente de la infraestructura de Android o iOS, no requiere configuraciones complejas de firmas SHA-1 ni archivos JSON adicionales en los directorios nativos.
El flujo operativo se ejecuta dentro de un componente basado en el paquete webview_flutter. Al iniciar la navegación, el usuario completa su autenticación y el WebView intercepta la respuesta mediante la propiedad navigationDelegate. Cuando detecta la URL de redirección configurada, extrae el parámetro code de la consulta de la URL y cierra la vista mediante Navigator.pop(context, code).
lib\pages\academy\user\github_webview_page.dart
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class GitHubWebViewPage extends StatefulWidget {
final String clientId;
final String redirectUri;
const GitHubWebViewPage({
Key? key,
required this.clientId,
required this.redirectUri,
}) : super(key: key);
@override
State<GitHubWebViewPage> createState() => _GitHubWebViewPageState();
}
class _GitHubWebViewPageState extends State<GitHubWebViewPage> {
late final WebViewController _controller;
bool _isLoading = true;
@override
void initState() {
super.initState();
final String authUrl =
'https://github.com/login/oauth/authorize?client_id=${widget.clientId}&redirect_uri=${Uri.encodeComponent(widget.redirectUri)}&scope=user:email';
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent("random") // Previene advertencias de navegadores no soportados por algunos OAuth
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (String url) {
setState(() {
_isLoading = true;
});
},
onPageFinished: (String url) {
setState(() {
_isLoading = false;
});
},
onNavigationRequest: (NavigationRequest request) {
final String url = request.url;
if (url.startsWith(widget.redirectUri)) {
// El redirectUri fue alcanzado. Extraemos el 'code' de los query parameters.
final Uri uri = Uri.parse(url);
final String? code = uri.queryParameters['code'];
if (code != null) {
Navigator.of(context).pop(code); // Retornamos el código de autorización
return NavigationDecision.prevent;
}
}
return NavigationDecision.navigate;
},
),
)
..loadRequest(Uri.parse(authUrl));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('GitHub Login'),
backgroundColor: const Color(0xFF0F111A),
),
body: Stack(
children: [
WebViewWidget(controller: _controller),
if (_isLoading)
const Center(
child: CircularProgressIndicator(),
),
],
),
);
}
}
4. Intercambio de Tokens y el Problema del Email Privado
Una vez que la aplicación móvil recupera este código intermedio, lo envía al endpoint unificado de nuestro backend (api/auth/social). El backend discrimina mediante el parámetro provider: 'github' y realiza la petición POST directa a los servidores de GitHub para obtener el token de acceso final. Se recomienda centralizar este paso en el servidor para evitar exponer secretos de cliente (Client Secret) en el código de la aplicación móvil y reducir la carga de procesamiento en el dispositivo.
Es crucial diferenciar el comportamiento entre proveedores: el token que devuelve Google ya contiene toda la información lista y procesada por sus servicios internos. En cambio, GitHub requiere este paso intermedio debido al manejo de la privacidad de sus usuarios, donde con frecuencia no se devuelve el correo electrónico de forma pública.
Para solucionar la ausencia del correo electrónico (un campo obligatorio en la mayoría de los sistemas de autenticación y verificación de usuarios), el backend implementa una validación restrictiva:
- Si tras procesar el token el email es nulo, el backend detiene el registro y retorna una estructura condicional: requires_email => true, junto con los datos públicos disponibles (social_data).
- Al recibir esta respuesta, la aplicación móvil intercepta el estado en el método _handleSocialBackendResponse y despliega un diálogo modal estructurado (_showEmailRegistrationDialog) que solicita al usuario ingresar su correo de manera manual.
- Una vez validado el formato del correo, se realiza una segunda petición al endpoint especializado para registrar el email junto a la data social remanente, disparando los eventos internos del sistema, creando el registro definitivo del usuario en la base de datos y retornando el token de sesión final (Sanctum o similar).
void _showEmailRegistrationDialog(
Map<String, dynamic> socialData, String provider) {
final TextEditingController emailController = TextEditingController();
bool dialogLoading = false;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
backgroundColor: const Color(0xFF1E1B4B),
title: Text(
LocaleKeys.completeRegistration.tr(),
style:
TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
LocaleKeys.dialogRequiresEmailDescription.tr(
namedArgs: {
'name': socialData['name'],
},
),
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'ejemplo@correo.com',
hintStyle:
TextStyle(color: Colors.white.withOpacity(0.3)),
filled: true,
fillColor: Colors.white.withOpacity(0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
],
),
actions: [
TextButton(
onPressed: dialogLoading
? null
: () {
Navigator.of(context).pop();
},
child: Text(LocaleKeys.cancel.tr(),
style: TextStyle(color: Colors.white.withOpacity(0.55))),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6366F1),
),
onPressed: dialogLoading
? null
: () async {
final email = emailController.text.trim();
if (email.isEmpty || !email.contains('@')) {
showToastMessage(context,
LocaleKeys.invalidEmailNotification.tr());
return;
}
setDialogState(() {
dialogLoading = true;
});
final result =
await _socialAuthService.registerSocialEmail(
email: email,
provider: provider,
socialId: socialData['id']?.toString() ?? '',
name: socialData['name'] ?? 'User',
avatar: socialData['avatar'],
);
setDialogState(() {
dialogLoading = false;
});
if (result['error'] == true) {
showToastMessage(
context,
result['message'] ??
LocaleKeys.errorRegisterEmail.tr());
} else {
Navigator.of(context).pop(); // Cierra el modal
_completeUserLogin(result, email);
}
},
child: dialogLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Text(LocaleKeys.register.tr(),
style: TextStyle(color: Colors.white)),
),
],
);
},
);
},
);
}En el backend, registramos el email:
public function storeSocialEmail(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'provider' => ['required', 'string'],
'social_id' => ['required', 'string'],
'name' => ['required', 'string'],
'avatar' => ['nullable', 'string'],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
$request->provider . '_id' => $request->social_id,
'avatar' => $request->avatar,
'password' => Hash::make(Str::random(24)),
'email_verified_at' => now(),
]);
event(new Registered($user));
// event(new Verified($user));
return $this->generateAuthResponse($user, 'Usuario creado con email proveído de forma manual.');
}
/**
* Genera la respuesta estándar de API con el Token de Sanctum.
*/
private function generateAuthResponse(User $user, string $message): JsonResponse
{
return response()->json([
'message' => $message,
'access_token' => getUserTokenAuth(user: $user),
'token_type' => 'Bearer',
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar' => $user->avatar,
]
], 200);
}Resumen del Flujo General
- Paso 1: Configuración previa de credenciales de la OAuth App en la consola de GitHub.
- Paso 2: Invocación de la autenticación web desde Flutter mediante un WebView personalizado para capturar el código de autorización.
- Paso 3: Envío del código al backend y procesamiento mediante Laravel Socialite.
- Paso 4: Validación y control del correo electrónico. Si es privado, se solicita mediante un modal en la app y se confirma en un endpoint secundario para concluir el inicio de sesión exitoso.
Migración para el usuario
En Laravel, creamos una migración para agregar tres columnas para el login social, los IDs únicos de los proveedores y el avatar:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('github_id')->nullable()->unique();
$table->string('google_id')->nullable()->unique();
$table->string('avatar')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['github_id', 'google_id', 'avatar']);
});
}
};Si vas a emplear más proveedores sociales, puedes crear una única columna llamada por ejemplo social_provider_id y un tipo enumerado para indicar el tipo de red social.
Otra funcionalidad muy útil es la de Detectar conexión a Internet en Flutter.