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.instance e serve chiamare initialize() una volta sola;
  • signIn() non esiste più: c'è authenticate() per il login esplicito e attemptLightweightAuthentication() per il ripristino silenzioso della sessione;
  • autenticazione e autorizzazione sono separate: authentication restituisce solo l'idToken; per ottenere un access token e chiamare le API Google si usa authorizationClient;
  • 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.