Niveau : Intermédiaire / Avancé
Technologies : Flutter (Dart), Laravel 13 (PHP), Laravel Fortify, Laravel Sanctum, Laravel Socialite, Google Cloud OAuth 2.0
Introduction
L'intégration d'un bouton "Continuer avec Google" dans une application mobile est devenue un standard indispensable d'expérience utilisateur (UX). Cependant, lorsqu'une application possède à la fois un front-end Web (ex: Nuxt/Vue) et une application mobile (Flutter), la gestion de l'authentification Google pose souvent des maux de tête architecturaux :
Le Web utilise des sessions stateful par cookies de domaine (gérées par Fortify).
Le Mobile utilise des jetons d'accès API stateless (Sanctum Bearer Tokens).
Dans cet article complet, nous allons vous expliquer pas à pas comment mettre en place une Architecture Dual Flow robuste, sécurisée et parfaitement synchronisée entre une application mobile Flutter et un backend Laravel.
ÉTAPE 1 : Pré-requis Google Cloud Console (La Base)
Avant d'écrire la moindre ligne de code, vous devez configurer vos identifiants sur la Google Cloud Console.
1. Créer ou Sélectionner un Projet
Rendez-vous sur Google Cloud Console et créez un projet.
2. Configurer l'Écran de Consentement OAuth
Dans APIs & Services > OAuth consent screen :
Choisissez le type d'utilisateur (External).
Renseignez le nom de votre application (ex:
nomdomaine App), votre e-mail de support et les domaines autorisés (ex:nomdomaine.com).Ajoutez les scopes de base :
.../auth/userinfo.emailet.../auth/userinfo.profile.
3. Créer un Identifiant Client OAuth 2.0 (Type Application Web)
Dans Credentials > Create Credentials > OAuth client ID :
Application type : Application Web.
Nom :
nomdomaine Backend API.URIs de redirection autorisés (CRUCIAL) :
Pour le Web :
https://nomdomaine.com/auth/google/callbackPour le Mobile :
https://api.nomdomaine.com/api/auth/google/mobile/callback
Remarque importante : Pour le flux mobile WebView + Deep Link, nous réutilisons les identifiants OAuth de type Web Client ID sur le serveur backend. Cela évite d'avoir à manipuler des empreintes SHA-1 complexes d'Android Keystore sur Google Cloud !
ÉTAPE 2 : Pré-requis Base de Données (Laravel Schema)
Votre table users doit pouvoir accueillir les comptes créés ou liés via Google OAuth.
Migration Laravel
Schema::table('users', function (Blueprint $table) {
$table->string('google_id')->nullable()->index();
$table->text('google_token')->nullable();
$table->text('google_refresh_token')->nullable();
$table->boolean('has_custom_password')->default(true);
});
Attributs du Modèle User.php
Assurez-vous d'ajouter google_id, google_token et google_refresh_token dans l'attribut fillable de votre modèle User, et d'inclure le trait HasApiTokens de Sanctum :
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
// ...
}
ÉTAPE 3 : Installation des Packages Backend (Fortify, Sanctum & Socialite)
Pour gérer à la fois l'authentification web/API, la gestion des sessions/mots de passe et l'OAuth Socialite, nous avons besoin de trois briques fondamentales dans Laravel :
1. Installation de Laravel Fortify (Gestion de l'Auth de Base & Profil)
Fortify est le moteur headless d'authentification de Laravel. Il gère l'inscription, la réinitialisation de mot de passe, la vérification d'e-mail et le 2FA sans imposer de vues Blade.
composer require laravel/fortify
php artisan fortify:install
php artisan migrateDans config/fortify.php, activez les fonctionnalités souhaitées (ex: Features::emailVerification(), Features::updatePasswords()).
2. Installation de Laravel Sanctum (Jetons d'Accès Bearer pour Mobile)
Sanctum est indispensable pour la partie mobile Flutter. Il permet de délivrer des Personal Access Tokens (plainTextToken) envoyés dans le header HTTP Authorization: Bearer <token> de chaque requête mobile.
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrateAssurez-vous que le middleware Sanctum est configuré dans bootstrap/app.php :
->withMiddleware(function (Middleware $middleware) {
$middleware->statefulApi();
})ÉTAPE 4 : Implémentation du Contrôleur Backend (MobileGoogleController.php)
Créons un contrôleur spécifiquement taillé pour les besoins de l'API mobile :
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Core;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
class MobileGoogleController extends Controller
{
/**
* Génère l'URL d'autorisation Google OAuth pour l'application Mobile.
*/
public function redirect(Request $request): JsonResponse
{
$ref = $request->query('ref');
// Forcer le HTTPS sur la route de callback mobile pour éviter le bug redirect_uri_mismatch
$callbackUrl = secure_url('/api/auth/google/mobile/callback');
$driver = Socialite::driver('google')->redirectUrl($callbackUrl)->stateless();
$stateItems = ['source=mobile'];
if (! empty($ref)) {
$stateItems[] = 'ref=' . $ref;
}
// Forcer Google à afficher l'écran de choix de compte Gmail (prompt=select_account)
$driver->with([
'prompt' => 'select_account',
'state' => implode('|', $stateItems),
]);
return response()->json([
'success' => true,
'url' => $driver->redirect()->getTargetUrl(),
]);
}
/**
* Reçoit le callback de Google, réconcilie le compte DB et redirige vers le Deep Link Mobile.
*/
public function callback(Request $request): RedirectResponse|JsonResponse
{
try {
$callbackUrl = secure_url('/api/auth/google/mobile/callback');
$googleUser = Socialite::driver('google')->redirectUrl($callbackUrl)->stateless()->user();
} catch (\Exception $e) {
return redirect()->to('nomdomaine://auth/callback?error=' . urlencode($e->getMessage()));
}
// Réconciliation DB intelligente (recherche par google_id -> email -> création)
$user = User::where('google_id', $googleUser->getId())->first();
if (! $user) {
$user = User::where('email', $googleUser->getEmail())->first();
if ($user instanceof User) {
// Liaison de compte existant
$user->update([
'google_id' => $googleUser->getId(),
'google_token' => $googleUser->token,
'google_refresh_token' => $googleUser->refreshToken ?? null,
'email_verified_at' => $user->email_verified_at ?? now(),
]);
} else {
// Création d'un nouveau compte
$user = DB::transaction(function () use ($googleUser, $request) {
$u = User::create([
'name' => $googleUser->getName() ?? 'Utilisateur Google',
'email' => $googleUser->getEmail(),
'password' => Hash::make(Str::random(32)),
'has_custom_password' => false,
'google_id' => $googleUser->getId(),
'google_token' => $googleUser->token,
'email_verified_at' => now(),
'registration_ip' => $request->ip(),
]);
$u->profile()->create(['avatar' => $googleUser->getAvatar()]);
return $u;
});
}
}
// Vérification de sécurité si l'utilisateur est banni
if ($user->is_banned) {
return redirect()->to('nomdomaine://auth/callback?error=' . urlencode("Votre compte est suspendu."));
}
// Génération du jeton Sanctum pour l'application Mobile
$token = $user->createToken('mobile-app')->plainTextToken;
$params = http_build_query([
'token' => $token,
'name' => $user->name,
'email' => $user->email,
'avatar' => $user->profile?->avatar ?? '',
]);
// Redirection HTTP 302 vers le Deep Link Mobile
return redirect()->to("nomdomaine://auth/callback?{$params}");
}
}Déclaration des Routes (routes/api.php)
Route::get('/auth/google/mobile/redirect', [MobileGoogleController::class, 'redirect']);
Route::match(['get', 'post'], '/auth/google/mobile/callback', [MobileGoogleController::class, 'callback']);
ÉTAPE 5 : Configuration Mobile Android & iOS (Deep Links)
Pour que la redirection nomdomaine://auth/callback ouvre ou communique avec l'application mobile :
Android (android/app/src/main/AndroidManifest.xml)
Ajoutez un <intent-filter> sous l'activité principale <activity> :
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="nomdomaine"
android:host="auth"/>
</intent-filter>
ÉTAPE 6 : Implémentation Front-End (Flutter)
1. Dépendances Flutter (pubspec.yaml)
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^x.x.x
flutter_inappwebview: ^x.x.x # ou webview_flutter
flutter_secure_storage: ^x.x.x
dio: ^x.x.x2. Écran WebView OAuth (google_oauth_webview_screen.dart)
L'écran WebView charge l'URL Google transmise par l'API et intercepte le callback contenant le jeton Sanctum :
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class GoogleOAuthWebViewScreen extends ConsumerStatefulWidget {
final String initialUrl;
const GoogleOAuthWebViewScreen({super.key, required this.initialUrl});
@override
ConsumerState<GoogleOAuthWebViewScreen> createState() => _GoogleOAuthWebViewScreenState();
}
class _GoogleOAuthWebViewScreenState extends ConsumerState<GoogleOAuthWebViewScreen> {
bool _isProcessing = false;
bool _checkCallbackUrl(String url) {
if (_isProcessing) return false;
// 1. Interception du jeton Sanctum dans l'URL (nomdomaine://auth/callback?token=...)
if (url.contains('token=')) {
_isProcessing = true;
String? token;
try {
final uri = Uri.parse(url);
token = uri.queryParameters['token'];
} catch (_) {}
if (token != null && token.isNotEmpty) {
_handleTokenSuccess(token);
return true;
}
}
// 2. Détection d'une erreur
if (url.contains('error=')) {
_isProcessing = true;
final uri = Uri.parse(url);
final error = uri.queryParameters['error'];
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${error ?? "Authentification annulée"}'),
backgroundColor: Colors.red,
),
);
Navigator.of(context).pop(false);
return true;
}
return false;
}
void _handleTokenSuccess(String token) async {
// Enregistrer le token dans FlutterSecureStorage
await ref.read(secureStorageServiceProvider).saveAuthToken(token);
// Hydrater le profil utilisateur via GET /api/auth/me
await ref.read(authNotifierProvider.notifier).fetchCurrentUser();
if (mounted) {
Navigator.of(context).pop(true); // Succès !
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Connexion avec Google")),
body: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(widget.initialUrl)),
onLoadStart: (controller, url) {
if (url != null) _checkCallbackUrl(url.toString());
},
onLoadStop: (controller, url) {
if (url != null) _checkCallbackUrl(url.toString());
},
),
);
}
}ÉTAPE 7 : Résolution des Pièges & Bugs Fréquents (Leçons Apprises)
1. Erreur Google redirect_uri_mismatch (Code 400)
Cause : Si votre serveur Laravel fonctionne derrière un Reverse Proxy (Traefik, Nginx, FrankenPHP), Socialite peut générer un schéma HTTP
http://api.nomdomaine.com/...au lieu dehttps://.Solution : Utilisez toujours
secure_url('/api/auth/google/mobile/callback')pour forcer le protocolehttps://. Déclarez précisément cet exact URL dans Google Cloud Console.
2. Interception prématurée dans la WebView
Cause : Si votre fonction de vérification d'URL écoute de façon trop large (ex:
url.contains('nomdomaine.com')), la WebView peut s'arrêter dès le chargement du callback backend avant même que Google ne redirige !Solution : Ne fermez la WebView qu'en cas de présence explicite de
token=ouerror=.
3. Gestion propre du champ mot de passe local
Problème : Lors d'une connexion via Google, aucun mot de passe n'existe localement. Si votre service de stockage garde un ancien mot de passe en cache, l'écran de profil risque d'afficher de faux masques
••••••••.Solution : Effacez systématiquement la clé
saved_passworddans votreSecureStoragelors d'une connexion réussie par Google OAuth.
Conclusion
En combinant Fortify pour le socle d'authentification, Sanctum pour la génération de jetons d'accès API mobile, et Socialite pour la réconciliation des comptes Google OAuth, vous obtenez une architecture propre, moderne et extrêmement sécurisée aussi bien sur Web que sur Flutter !






