[{"data":1,"prerenderedAt":27},["ShallowReactive",2],{"articolo-oauth2-e-openid-connect-in-flutter-con-flutter-appauth-login-sicuro-e-refresh-token":3,"comments-article-oauth2-e-openid-connect-in-flutter-con-flutter-appauth-login-sicuro-e-refresh-token":26},{"id":4,"title":5,"slug":6,"excerpt":7,"body":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"status":16,"published_at":17,"meta_title":18,"meta_description":19,"category":20,"author":24},143,"OAuth2 e OpenID Connect in Flutter con flutter_appauth: login sicuro e refresh token","oauth2-e-openid-connect-in-flutter-con-flutter-appauth-login-sicuro-e-refresh-token","Come integrare un Identity Provider (Keycloak, Auth0, Okta, Entra ID) in un'app Flutter rispettando le best practice: PKCE, custom tabs, storage sicuro dei token e refresh automatico con un interceptor Dio a prova di race condition.","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.\n\nIn 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.\n\n## Perché non usare una WebView\n\nLa 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:\n\n- l'app ospita il form di login, quindi potrebbe leggere le credenziali: viene meno la garanzia di sicurezza;\n- non si riusa la sessione del browser di sistema, quindi niente SSO tra app;\n- password manager e autofill di sistema funzionano male o per nulla.\n\n`flutter_appauth` è un wrapper sulle librerie native [AppAuth](https:\u002F\u002Fappauth.io) 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.\n\n## Setup\n\n```yaml\ndependencies:\n  flutter_appauth: ^8.0.0\n  flutter_secure_storage: ^9.2.2\n  dio: ^5.7.0\n```\n\n### Android\n\nIl redirect URI usa uno schema custom che deve essere dichiarato in `android\u002Fapp\u002Fbuild.gradle`:\n\n```groovy\nandroid {\n    defaultConfig {\n        manifestPlaceholders += [\n            'appAuthRedirectScheme': 'com.example.myapp'\n        ]\n    }\n}\n```\n\nSu Android 11+ ricordati anche delle `queries` nel manifest, altrimenti il plugin non riesce a risolvere il browser:\n\n```xml\n\u003Cqueries>\n  \u003Cintent>\n    \u003Caction android:name=\"android.intent.action.VIEW\" \u002F>\n    \u003Ccategory android:name=\"android.intent.category.BROWSABLE\" \u002F>\n    \u003Cdata android:scheme=\"https\" \u002F>\n  \u003C\u002Fintent>\n\u003C\u002Fqueries>\n```\n\n### iOS\n\nIn `ios\u002FRunner\u002FInfo.plist` dichiara lo schema:\n\n```xml\n\u003Ckey>CFBundleURLTypes\u003C\u002Fkey>\n\u003Carray>\n  \u003Cdict>\n    \u003Ckey>CFBundleURLSchemes\u003C\u002Fkey>\n    \u003Carray>\n      \u003Cstring>com.example.myapp\u003C\u002Fstring>\n    \u003C\u002Farray>\n  \u003C\u002Fdict>\n\u003C\u002Farray>\n```\n\n> **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_appauth` gestisce automaticamente.\n\n## Il flusso di login\n\nCon il discovery endpoint di OpenID Connect non serve scrivere a mano gli URL di authorization e token: basta l'issuer.\n\n```dart\nimport 'package:flutter_appauth\u002Fflutter_appauth.dart';\n\nclass AuthConfig {\n  static const clientId = 'my-flutter-app';\n  static const issuer = 'https:\u002F\u002Fauth.example.com\u002Frealms\u002Fmyrealm';\n  static const redirectUrl = 'com.example.myapp:\u002Foauthredirect';\n  static const postLogoutRedirectUrl = 'com.example.myapp:\u002Flogout';\n  static const scopes = ['openid', 'profile', 'email', 'offline_access'];\n}\n```\n\nLo 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.\n\n```dart\nclass AuthService {\n  AuthService(this._appAuth, this._storage);\n\n  final FlutterAppAuth _appAuth;\n  final FlutterSecureStorage _storage;\n\n  Future\u003Cvoid> signIn() async {\n    final result = await _appAuth.authorizeAndExchangeCode(\n      AuthorizationTokenRequest(\n        AuthConfig.clientId,\n        AuthConfig.redirectUrl,\n        issuer: AuthConfig.issuer,\n        scopes: AuthConfig.scopes,\n        promptValues: ['login'], \u002F\u002F forza il form anche con sessione attiva\n      ),\n    );\n    await _persist(result);\n  }\n}\n```\n\n`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()`.\n\n## Salvare i token\n\nI token vanno in `flutter_secure_storage`, che usa Keychain su iOS ed EncryptedSharedPreferences su Android. Mai in `SharedPreferences` in chiaro.\n\n```dart\nFuture\u003Cvoid> _persist(TokenResponse? r) async {\n  if (r == null) throw const AuthException('Risposta token vuota');\n  await Future.wait([\n    _storage.write(key: 'access_token', value: r.accessToken),\n    _storage.write(key: 'id_token', value: r.idToken),\n    if (r.refreshToken != null)\n      _storage.write(key: 'refresh_token', value: r.refreshToken),\n    _storage.write(\n      key: 'expires_at',\n      value: r.accessTokenExpirationDateTime?.toIso8601String(),\n    ),\n  ]);\n}\n```\n\nAttenzione 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.\n\n## Il refresh: il punto dove si sbaglia di più\n\nIl 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.\n\nLa soluzione è **serializzare** il refresh con un `Completer` (o un mutex) condiviso.\n\n```dart\nFuture\u003CString>? _refreshInFlight;\n\nFuture\u003CString> getValidAccessToken() async {\n  final expiresAt = await _storage.read(key: 'expires_at');\n  final token = await _storage.read(key: 'access_token');\n\n  final expiry = expiresAt != null ? DateTime.tryParse(expiresAt) : null;\n  final isExpiring = expiry == null ||\n      expiry.isBefore(DateTime.now().add(const Duration(seconds: 30)));\n\n  if (token != null && !isExpiring) return token;\n\n  \u002F\u002F Una sola richiesta di refresh alla volta.\n  return _refreshInFlight ??= _refresh().whenComplete(() {\n    _refreshInFlight = null;\n  });\n}\n\nFuture\u003CString> _refresh() async {\n  final refreshToken = await _storage.read(key: 'refresh_token');\n  if (refreshToken == null) throw const AuthException('Sessione scaduta');\n\n  try {\n    final result = await _appAuth.token(TokenRequest(\n      AuthConfig.clientId,\n      AuthConfig.redirectUrl,\n      issuer: AuthConfig.issuer,\n      refreshToken: refreshToken,\n      scopes: AuthConfig.scopes,\n    ));\n    await _persist(result);\n    return result!.accessToken!;\n  } on FlutterAppAuthUserCancelledException {\n    rethrow;\n  } catch (e) {\n    \u002F\u002F refresh token revocato o scaduto: serve un nuovo login\n    await signOutLocal();\n    throw const AuthException('Sessione scaduta', requiresLogin: true);\n  }\n}\n```\n\nIl 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.\n\n## Integrazione con Dio\n\nPer l'interceptor usa `QueuedInterceptorsWrapper`: a differenza di `InterceptorsWrapper`, processa le richieste in coda, evitando che venti chiamate attraversino contemporaneamente la logica di refresh.\n\n```dart\nclass AuthInterceptor extends QueuedInterceptor {\n  AuthInterceptor(this._auth, this._dio);\n\n  final AuthService _auth;\n  final Dio _dio;\n\n  @override\n  Future\u003Cvoid> onRequest(\n    RequestOptions options,\n    RequestInterceptorHandler handler,\n  ) async {\n    if (options.extra['skipAuth'] == true) return handler.next(options);\n    try {\n      final token = await _auth.getValidAccessToken();\n      options.headers['Authorization'] = 'Bearer $token';\n      handler.next(options);\n    } on AuthException catch (e) {\n      handler.reject(DioException(requestOptions: options, error: e));\n    }\n  }\n\n  @override\n  Future\u003Cvoid> onError(\n    DioException err,\n    ErrorInterceptorHandler handler,\n  ) async {\n    final alreadyRetried = err.requestOptions.extra['retried'] == true;\n    if (err.response?.statusCode != 401 || alreadyRetried) {\n      return handler.next(err);\n    }\n\n    try {\n      final token = await _auth.forceRefresh();\n      final options = err.requestOptions\n        ..headers['Authorization'] = 'Bearer $token'\n        ..extra['retried'] = true;\n      final response = await _dio.fetch(options);\n      handler.resolve(response);\n    } catch (_) {\n      handler.next(err);\n    }\n  }\n}\n```\n\nIl flag `retried` evita loop infiniti quando il backend risponde 401 per un motivo diverso dalla scadenza del token (per esempio permessi mancanti configurati male).\n\n## Logout\n\nIl 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).\n\n```dart\nFuture\u003Cvoid> signOut() async {\n  final idToken = await _storage.read(key: 'id_token');\n  await signOutLocal();\n  if (idToken == null) return;\n  try {\n    await _appAuth.endSession(EndSessionRequest(\n      idTokenHint: idToken,\n      issuer: AuthConfig.issuer,\n      postLogoutRedirectUrl: AuthConfig.postLogoutRedirectUrl,\n    ));\n  } on FlutterAppAuthUserCancelledException {\n    \u002F\u002F l'utente ha chiuso il browser: la sessione locale è comunque pulita\n  }\n}\n\nFuture\u003Cvoid> signOutLocal() => _storage.deleteAll();\n```\n\n## Leggere i claim dell'ID token\n\nL'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.\n\n```dart\nMap\u003CString, dynamic> decodeClaims(String jwt) {\n  final parts = jwt.split('.');\n  if (parts.length != 3) throw const FormatException('JWT non valido');\n  final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));\n  return jsonDecode(payload) as Map\u003CString, dynamic>;\n}\n```\n\n## Esporre lo stato di autenticazione alla UI\n\nUn semplice `Stream` permette a `go_router` (tramite `refreshListenable`) o al tuo state manager di reagire a login e logout:\n\n```dart\nenum AuthStatus { unknown, authenticated, unauthenticated }\n\nfinal _controller = StreamController\u003CAuthStatus>.broadcast();\nStream\u003CAuthStatus> get status => _controller.stream;\n\nFuture\u003Cvoid> bootstrap() async {\n  final refreshToken = await _storage.read(key: 'refresh_token');\n  if (refreshToken == null) {\n    _controller.add(AuthStatus.unauthenticated);\n    return;\n  }\n  try {\n    await getValidAccessToken();\n    _controller.add(AuthStatus.authenticated);\n  } catch (_) {\n    _controller.add(AuthStatus.unauthenticated);\n  }\n}\n```\n\nChiamando `bootstrap()` allo splash screen l'utente che riapre l'app dopo giorni entra direttamente, senza rivedere il login.\n\n## Checklist finale\n\n- **PKCE sempre attivo** e client configurato come *public*: nessun secret nel binario.\n- **Redirect URI** con schema univoco (reverse domain) dichiarato su Android, iOS e lato IdP.\n- **Refresh serializzato**: un solo refresh in volo, con margine di sicurezza sulla scadenza.\n- **Rotazione dei refresh token** gestita: salva sempre quello nuovo.\n- **Storage sicuro** per tutti i token, con `deleteAll()` al logout.\n- **end_session** per chiudere davvero la sessione sull'IdP.\n- Su **desktop** il flusso cambia: serve un redirect su `http:\u002F\u002Flocalhost:\u003Cporta>` con un server HTTP temporaneo, oppure pacchetti dedicati come `oauth2_client`.\n\nImplementare 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.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Farticles\u002F64c1da1e-a195-4039-8e21-9ad8bad4e3d2.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1603899122634-f086ca5f5ddd?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3OTMzMzI4ODV8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"Franck","https:\u002F\u002Funsplash.com\u002F@franckinjapan","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fblack-iphone-5-on-yellow-textile-DoWZMPZ-M9s",null,"published","2026-10-30T04:01:25+00:00","OAuth2 in Flutter con flutter_appauth: guida completa","Integra OAuth2 e OpenID Connect in Flutter con flutter_appauth: PKCE, storage sicuro dei token, refresh automatico e interceptor Dio senza race condition.",{"id":21,"name":22,"slug":23},1,"Guide","guide",{"id":21,"name":25},"Flutter Bot",[],1793359903187]