Quando l'autenticazione non passa da Firebase ma da un Identity Provider aziendale — Keycloak, Auth0, Okta, Microsoft Entra ID o un server OAuth2 custom — serve implementare correttamente il flusso Authorization Code + PKCE. È il flusso raccomandato dalla RFC 8252 (OAuth 2.0 for Native Apps) ed è l'unico accettabile per un'app mobile.
In questa guida vediamo come farlo con flutter_appauth, come conservare i token in modo sicuro e soprattutto come gestire il refresh automatico senza generare decine di chiamate concorrenti.
Perché non usare una WebView
La tentazione è forte: aprire una WebView, intercettare l'URL di redirect ed estrarre il codice. È però una pratica sconsigliata e spesso bloccata dagli Identity Provider (Google la rifiuta esplicitamente), per tre motivi:
- l'app ospita il form di login, quindi potrebbe leggere le credenziali: viene meno la garanzia di sicurezza;
- non si riusa la sessione del browser di sistema, quindi niente SSO tra app;
- password manager e autofill di sistema funzionano male o per nulla.
flutter_appauth è un wrapper sulle librerie native AppAuth e usa Chrome Custom Tabs su Android e ASWebAuthenticationSession su iOS: il browser di sistema, con i cookie di sessione condivisi, ma senza uscire visivamente dall'app.
Setup
dependencies:
flutter_appauth: ^8.0.0
flutter_secure_storage: ^9.2.2
dio: ^5.7.0
Android
Il redirect URI usa uno schema custom che deve essere dichiarato in android/app/build.gradle:
android {
defaultConfig {
manifestPlaceholders += [
'appAuthRedirectScheme': 'com.example.myapp'
]
}
}
Su Android 11+ ricordati anche delle queries nel manifest, altrimenti il plugin non riesce a risolvere il browser:
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
</queries>
iOS
In ios/Runner/Info.plist dichiara lo schema:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.example.myapp</string>
</array>
</dict>
</array>
Nota sul client secret: in un'app mobile il client è public. Non inserire mai un client secret nel codice: è estraibile dal binario in pochi minuti. Configura il client come "public" sull'IdP e affidati a PKCE, che
flutter_appauthgestisce automaticamente.
Il flusso di login
Con il discovery endpoint di OpenID Connect non serve scrivere a mano gli URL di authorization e token: basta l'issuer.
import 'package:flutter_appauth/flutter_appauth.dart';
class AuthConfig {
static const clientId = 'my-flutter-app';
static const issuer = 'https://auth.example.com/realms/myrealm';
static const redirectUrl = 'com.example.myapp:/oauthredirect';
static const postLogoutRedirectUrl = 'com.example.myapp:/logout';
static const scopes = ['openid', 'profile', 'email', 'offline_access'];
}
Lo scope offline_access (o l'equivalente previsto dal tuo IdP) è ciò che ti garantisce il refresh token: senza, dovrai rifare il login a ogni scadenza dell'access token.
class AuthService {
AuthService(this._appAuth, this._storage);
final FlutterAppAuth _appAuth;
final FlutterSecureStorage _storage;
Future<void> signIn() async {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
AuthConfig.clientId,
AuthConfig.redirectUrl,
issuer: AuthConfig.issuer,
scopes: AuthConfig.scopes,
promptValues: ['login'], // forza il form anche con sessione attiva
),
);
await _persist(result);
}
}
authorizeAndExchangeCode fa in un colpo solo l'apertura del browser e lo scambio del codice con i token. Se ti serve separare i due passaggi (per esempio perché lo scambio avviene lato backend) puoi usare authorize() e poi token().
Salvare i token
I token vanno in flutter_secure_storage, che usa Keychain su iOS ed EncryptedSharedPreferences su Android. Mai in SharedPreferences in chiaro.
Future<void> _persist(TokenResponse? r) async {
if (r == null) throw const AuthException('Risposta token vuota');
await Future.wait([
_storage.write(key: 'access_token', value: r.accessToken),
_storage.write(key: 'id_token', value: r.idToken),
if (r.refreshToken != null)
_storage.write(key: 'refresh_token', value: r.refreshToken),
_storage.write(
key: 'expires_at',
value: r.accessTokenExpirationDateTime?.toIso8601String(),
),
]);
}
Attenzione a un dettaglio spesso ignorato: alcuni IdP applicano la rotazione dei refresh token, cioè a ogni refresh ne emettono uno nuovo e invalidano il precedente. Per questo l'if (r.refreshToken != null) è importante — e lo è ancora di più salvare sempre il nuovo valore quando arriva.
Il refresh: il punto dove si sbaglia di più
Il problema classico: la home dell'app lancia cinque chiamate in parallelo, l'access token è scaduto, cinque interceptor rilevano il 401 e partono cinque refresh. Con la rotazione attiva, quattro falliscono e l'utente viene buttato fuori.
La soluzione è serializzare il refresh con un Completer (o un mutex) condiviso.
Future<String>? _refreshInFlight;
Future<String> getValidAccessToken() async {
final expiresAt = await _storage.read(key: 'expires_at');
final token = await _storage.read(key: 'access_token');
final expiry = expiresAt != null ? DateTime.tryParse(expiresAt) : null;
final isExpiring = expiry == null ||
expiry.isBefore(DateTime.now().add(const Duration(seconds: 30)));
if (token != null && !isExpiring) return token;
// Una sola richiesta di refresh alla volta.
return _refreshInFlight ??= _refresh().whenComplete(() {
_refreshInFlight = null;
});
}
Future<String> _refresh() async {
final refreshToken = await _storage.read(key: 'refresh_token');
if (refreshToken == null) throw const AuthException('Sessione scaduta');
try {
final result = await _appAuth.token(TokenRequest(
AuthConfig.clientId,
AuthConfig.redirectUrl,
issuer: AuthConfig.issuer,
refreshToken: refreshToken,
scopes: AuthConfig.scopes,
));
await _persist(result);
return result!.accessToken!;
} on FlutterAppAuthUserCancelledException {
rethrow;
} catch (e) {
// refresh token revocato o scaduto: serve un nuovo login
await signOutLocal();
throw const AuthException('Sessione scaduta', requiresLogin: true);
}
}
Il margine di 30 secondi serve a coprire la latenza di rete e un eventuale clock skew tra device e server: rinnovare un attimo prima è molto meglio che incassare un 401.
Integrazione con Dio
Per l'interceptor usa QueuedInterceptorsWrapper: a differenza di InterceptorsWrapper, processa le richieste in coda, evitando che venti chiamate attraversino contemporaneamente la logica di refresh.
class AuthInterceptor extends QueuedInterceptor {
AuthInterceptor(this._auth, this._dio);
final AuthService _auth;
final Dio _dio;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (options.extra['skipAuth'] == true) return handler.next(options);
try {
final token = await _auth.getValidAccessToken();
options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
} on AuthException catch (e) {
handler.reject(DioException(requestOptions: options, error: e));
}
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final alreadyRetried = err.requestOptions.extra['retried'] == true;
if (err.response?.statusCode != 401 || alreadyRetried) {
return handler.next(err);
}
try {
final token = await _auth.forceRefresh();
final options = err.requestOptions
..headers['Authorization'] = 'Bearer $token'
..extra['retried'] = true;
final response = await _dio.fetch(options);
handler.resolve(response);
} catch (_) {
handler.next(err);
}
}
}
Il flag retried evita loop infiniti quando il backend risponde 401 per un motivo diverso dalla scadenza del token (per esempio permessi mancanti configurati male).
Logout
Il logout va fatto su due livelli: locale (cancellare i token) e remoto (chiudere la sessione sull'IdP, altrimenti al login successivo il browser riconosce la sessione e rientra senza chiedere nulla).
Future<void> signOut() async {
final idToken = await _storage.read(key: 'id_token');
await signOutLocal();
if (idToken == null) return;
try {
await _appAuth.endSession(EndSessionRequest(
idTokenHint: idToken,
issuer: AuthConfig.issuer,
postLogoutRedirectUrl: AuthConfig.postLogoutRedirectUrl,
));
} on FlutterAppAuthUserCancelledException {
// l'utente ha chiuso il browser: la sessione locale è comunque pulita
}
}
Future<void> signOutLocal() => _storage.deleteAll();
Leggere i claim dell'ID token
L'ID token è un JWT: puoi decodificarlo lato client per mostrare nome ed email, ma non usarlo mai per decisioni di sicurezza senza verificarne la firma — quella è responsabilità del backend.
Map<String, dynamic> decodeClaims(String jwt) {
final parts = jwt.split('.');
if (parts.length != 3) throw const FormatException('JWT non valido');
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
return jsonDecode(payload) as Map<String, dynamic>;
}
Esporre lo stato di autenticazione alla UI
Un semplice Stream permette a go_router (tramite refreshListenable) o al tuo state manager di reagire a login e logout:
enum AuthStatus { unknown, authenticated, unauthenticated }
final _controller = StreamController<AuthStatus>.broadcast();
Stream<AuthStatus> get status => _controller.stream;
Future<void> bootstrap() async {
final refreshToken = await _storage.read(key: 'refresh_token');
if (refreshToken == null) {
_controller.add(AuthStatus.unauthenticated);
return;
}
try {
await getValidAccessToken();
_controller.add(AuthStatus.authenticated);
} catch (_) {
_controller.add(AuthStatus.unauthenticated);
}
}
Chiamando bootstrap() allo splash screen l'utente che riapre l'app dopo giorni entra direttamente, senza rivedere il login.
Checklist finale
- PKCE sempre attivo e client configurato come public: nessun secret nel binario.
- Redirect URI con schema univoco (reverse domain) dichiarato su Android, iOS e lato IdP.
- Refresh serializzato: un solo refresh in volo, con margine di sicurezza sulla scadenza.
- Rotazione dei refresh token gestita: salva sempre quello nuovo.
- Storage sicuro per tutti i token, con
deleteAll()al logout. - end_session per chiudere davvero la sessione sull'IdP.
- Su desktop il flusso cambia: serve un redirect su
http://localhost:<porta>con un server HTTP temporaneo, oppure pacchetti dedicati comeoauth2_client.
Implementare OAuth2 correttamente richiede un po' di disciplina, ma il risultato è una sessione che si rinnova da sola, resiste alle chiamate concorrenti e non costringe mai l'utente a rifare il login senza motivo.