L'autenticazione è uno dei primi requisiti di quasi ogni app reale, ed è anche uno dei punti in cui è più facile sbagliare: flussi diversi per piattaforma, token da rinnovare, errori da tradurre in messaggi comprensibili, rotte da proteggere. In questa guida costruiamo un layer di autenticazione completo con Firebase Auth, integrando email/password, Google Sign-In (con la nuova API 7.x, che ha introdotto breaking change importanti) e Sign in with Apple, obbligatorio su iOS se offri altri login social.
Setup del progetto
Dopo aver creato il progetto su Firebase e averlo collegato con flutterfire configure, le dipendenze minime sono:
dependencies:
firebase_core: ^3.8.0
firebase_auth: ^5.3.0
google_sign_in: ^7.1.0
go_router: ^14.0.0
L'inizializzazione avviene nel main:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyApp());
}
Ricorda di abilitare i provider desiderati nella console Firebase (Authentication → Sign-in method): senza quel passaggio il client riceverà operation-not-allowed.
Un repository per isolare Firebase
Esporre FirebaseAuth direttamente nella UI rende il codice difficile da testare e da migrare. Meglio incapsulare tutto in un repository che espone modelli e errori di dominio.
class AuthRepository {
AuthRepository({FirebaseAuth? auth})
: _auth = auth ?? FirebaseAuth.instance;
final FirebaseAuth _auth;
/// Emette l'utente corrente a ogni login/logout.
Stream<User?> get authStateChanges => _auth.authStateChanges();
/// Emette anche al refresh del token e ai cambi di profilo.
Stream<User?> get userChanges => _auth.userChanges();
User? get currentUser => _auth.currentUser;
Future<void> signOut() => _auth.signOut();
}
È utile conoscere la differenza fra i tre stream disponibili:
authStateChanges(): login, logout e cambio utente. È quello da usare per il routing.idTokenChanges(): emette anche quando l'ID token viene rinnovato (ogni ora circa).userChanges(): aggiunge gli aggiornamenti al profilo (displayName,photoURL, verifica email).
Login con email e password
Il flusso classico, con registrazione, verifica dell'indirizzo e reset della password:
extension EmailAuth on AuthRepository {
Future<UserCredential> signInWithEmail({
required String email,
required String password,
}) {
return _auth.signInWithEmailAndPassword(
email: email.trim(),
password: password,
);
}
Future<UserCredential> registerWithEmail({
required String email,
required String password,
}) async {
final credential = await _auth.createUserWithEmailAndPassword(
email: email.trim(),
password: password,
);
await credential.user?.sendEmailVerification();
return credential;
}
Future<void> sendPasswordReset(String email) =>
_auth.sendPasswordResetEmail(email: email.trim());
}
Nota:
_authè privato, quindi in un progetto reale i metodi vanno dentro la classe. L'extension qui è solo per leggibilità dell'articolo.
Tradurre gli errori
FirebaseAuthException espone un code stabile: mapparlo in un enum di dominio evita di mostrare messaggi in inglese all'utente e rende testabile la logica.
enum AuthFailure {
invalidCredentials,
emailAlreadyInUse,
weakPassword,
tooManyRequests,
network,
requiresRecentLogin,
cancelled,
unknown;
static AuthFailure fromCode(String code) => switch (code) {
'invalid-credential' ||
'wrong-password' ||
'user-not-found' =>
AuthFailure.invalidCredentials,
'email-already-in-use' => AuthFailure.emailAlreadyInUse,
'weak-password' => AuthFailure.weakPassword,
'too-many-requests' => AuthFailure.tooManyRequests,
'network-request-failed' => AuthFailure.network,
'requires-recent-login' => AuthFailure.requiresRecentLogin,
_ => AuthFailure.unknown,
};
}
String messageFor(AuthFailure failure) => switch (failure) {
AuthFailure.invalidCredentials => 'Email o password non corretti.',
AuthFailure.emailAlreadyInUse => 'Esiste già un account con questa email.',
AuthFailure.weakPassword => 'La password è troppo debole.',
AuthFailure.tooManyRequests => 'Troppi tentativi, riprova più tardi.',
AuthFailure.network => 'Connessione assente.',
AuthFailure.requiresRecentLogin => 'Rieffettua il login per continuare.',
AuthFailure.cancelled => 'Operazione annullata.',
AuthFailure.unknown => 'Si è verificato un errore imprevisto.',
};
Per motivi di sicurezza Firebase, con email enumeration protection attiva, restituisce invalid-credential sia per password sbagliata sia per utente inesistente: non provare a distinguere i due casi nella UI.
Google Sign-In: cosa cambia con la versione 7
La 7.0 di google_sign_in ha riscritto l'API. I punti chiave:
- si usa il singleton
GoogleSignIn.instancee serve chiamareinitialize()una volta sola; signIn()non esiste più: c'èauthenticate()per il login esplicito eattemptLightweightAuthentication()per il ripristino silenzioso della sessione;- autenticazione e autorizzazione sono separate:
authenticationrestituisce solo l'idToken; per ottenere un access token e chiamare le API Google si usaauthorizationClient; - su Web
authenticate()non è supportato: bisogna usare il bottone renderizzato da Google.
import 'package:google_sign_in/google_sign_in.dart';
class GoogleAuthService {
Future<void> initialize() async {
await GoogleSignIn.instance.initialize(
// clientId iOS/macOS, serverClientId per validare il token lato backend
clientId: Platform.isIOS ? iosClientId : null,
serverClientId: webClientId,
);
}
Future<UserCredential?> signIn() async {
try {
final account = await GoogleSignIn.instance.authenticate(
scopeHint: const ['email', 'profile'],
);
final idToken = account.authentication.idToken;
if (idToken == null) throw StateError('ID token mancante');
final credential = GoogleAuthProvider.credential(idToken: idToken);
return FirebaseAuth.instance.signInWithCredential(credential);
} on GoogleSignInException catch (e) {
if (e.code == GoogleSignInExceptionCode.canceled) return null;
rethrow;
}
}
Future<void> signOut() async {
await GoogleSignIn.instance.signOut();
await FirebaseAuth.instance.signOut();
}
}
Se l'utente deve revocare completamente il consenso (ad esempio in fase di cancellazione dell'account) usa GoogleSignIn.instance.disconnect() al posto di signOut().
Su Android ricordati di registrare l'impronta SHA-1 (e SHA-256 per Play App Signing) nella console Firebase e di rigenerare google-services.json: il 90% dei sign_in_failed dipende da questo.
Sign in with Apple
Le linee guida App Store impongono Sign in with Apple se l'app offre login di terze parti. Con firebase_auth puoi evitare pacchetti aggiuntivi usando il provider nativo:
Future<UserCredential> signInWithApple() async {
final provider = AppleAuthProvider()
..addScope('email')
..addScope('name');
if (kIsWeb) {
return FirebaseAuth.instance.signInWithPopup(provider);
}
// Su iOS/macOS usa il flusso nativo; su Android apre una web view.
return FirebaseAuth.instance.signInWithProvider(provider);
}
Requisiti lato piattaforma:
- attivare la capability Sign in with Apple in Xcode (target Runner);
- configurare il provider Apple nella console Firebase con Services ID, Team ID e chiave privata (necessario per Android e Web);
- gestire il fatto che nome ed email arrivano solo al primo accesso: se non li salvi subito su Firestore o nel profilo utente, li perdi.
final credential = await signInWithApple();
final fullName = credential.additionalUserInfo?.profile?['name'];
if (credential.additionalUserInfo?.isNewUser ?? false) {
await credential.user?.updateDisplayName(fullName?.toString());
}
Molti utenti Apple usano l'indirizzo privato @privaterelay.appleid.com: se invii email transazionali, configura il relay nel pannello sviluppatore Apple.
Collegare più provider allo stesso account
Se un utente si registra con email e poi tenta il login con Google usando lo stesso indirizzo, puoi ottenere account-exists-with-different-credential. La soluzione è il linking:
Future<void> linkGoogle(AuthCredential credential) async {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
try {
await user.linkWithCredential(credential);
} on FirebaseAuthException catch (e) {
if (e.code == 'credential-already-in-use') {
// La credenziale appartiene già a un altro account: chiedi conferma
// e fai il merge dei dati applicativi prima di procedere.
await FirebaseAuth.instance.signInWithCredential(credential);
}
}
}
Operazioni sensibili come delete(), updatePassword() o updateEmail() richiedono un login recente: intercetta requires-recent-login e chiama user.reauthenticateWithCredential(...).
Proteggere le rotte con go_router
Il modo più pulito per gestire l'accesso alle schermate è collegare lo stream di autenticazione al redirect del router.
class GoRouterRefreshStream extends ChangeNotifier {
GoRouterRefreshStream(Stream<dynamic> stream) {
notifyListeners();
_sub = stream.asBroadcastStream().listen((_) => notifyListeners());
}
late final StreamSubscription<dynamic> _sub;
@override
void dispose() {
_sub.cancel();
super.dispose();
}
}
final router = GoRouter(
initialLocation: '/',
refreshListenable:
GoRouterRefreshStream(FirebaseAuth.instance.authStateChanges()),
redirect: (context, state) {
final loggedIn = FirebaseAuth.instance.currentUser != null;
final goingToLogin = state.matchedLocation == '/login';
if (!loggedIn && !goingToLogin) return '/login';
if (loggedIn && goingToLogin) return '/';
return null;
},
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
],
);
All'avvio Firebase impiega qualche millisecondo per ripristinare la sessione da disco: mostra una splash finché il primo evento dello stream non è arrivato, altrimenti l'utente vedrà un flash della schermata di login.
Inviare il token al tuo backend
Se hai un backend proprietario, non inviare mai lo uid come prova di identità: manda l'ID token e verificalo lato server con l'Admin SDK.
class AuthInterceptor extends Interceptor {
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await FirebaseAuth.instance.currentUser?.getIdToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
}
getIdToken() restituisce il token dalla cache e lo rinnova automaticamente se scaduto; usa getIdToken(true) solo quando hai appena modificato i custom claims e ti serve subito il valore aggiornato.
Testare il layer di autenticazione
Il vantaggio di avere un AuthRepository è poterlo sostituire con un fake nei test di widget:
class FakeAuthRepository implements AuthRepository {
final _controller = StreamController<User?>.broadcast();
@override
Stream<User?> get authStateChanges => _controller.stream;
void emitLoggedOut() => _controller.add(null);
// ...
}
Per i test end-to-end conviene usare l'Auth Emulator, che evita di consumare quota e di creare utenti reali:
if (kDebugMode) {
await FirebaseAuth.instance.useAuthEmulator('localhost', 9099);
}
Checklist finale
- Abilita App Check per impedire l'uso delle API Firebase da client non autorizzati.
- Attiva la protezione contro l'enumerazione delle email e i limiti di rate nella console.
- Non salvare mai la password localmente: la sessione la gestisce già Firebase in modo sicuro.
- Gestisci esplicitamente l'annullamento del login social (l'utente che chiude il foglio nativo non è un errore da segnalare con uno snackbar rosso).
- Prevedi la cancellazione dell'account: è un requisito sia di Apple sia di Google Play.
- Aggiorna il testo dei consensi e la privacy policy indicando quali dati raccogli dai provider.
Con questa struttura hai un sistema di autenticazione multi-provider testabile, disaccoppiato dalla UI e pronto a scalare: aggiungere in futuro un login con numero di telefono o un magic link significherà solo aggiungere un metodo al repository, senza toccare il routing né le schermate esistenti.