feat(mobile): Implement Keycloak WebView authentication with HTTP callback

- Replace flutter_appauth with custom WebView implementation to resolve deep link issues
- Add KeycloakWebViewAuthService with integrated WebView for seamless authentication
- Configure Android manifest for HTTP cleartext traffic support
- Add network security config for development environment (192.168.1.11)
- Update Keycloak client to use HTTP callback endpoint (http://192.168.1.11:8080/auth/callback)
- Remove obsolete keycloak_auth_service.dart and temporary scripts
- Clean up dependencies and regenerate injection configuration
- Tested successfully on multiple Android devices (Xiaomi 2201116TG, SM A725F)

BREAKING CHANGE: Authentication flow now uses WebView instead of external browser
- Users will see Keycloak login page within the app instead of browser redirect
- Resolves ERR_CLEARTEXT_NOT_PERMITTED and deep link state management issues
- Maintains full OIDC compliance with PKCE flow and secure token storage

Technical improvements:
- WebView with custom navigation delegate for callback handling
- Automatic token extraction and user info parsing from JWT
- Proper error handling and user feedback
- Consistent authentication state management across app lifecycle
This commit is contained in:
DahoudG
2025-09-15 01:44:16 +00:00
parent 73459b3092
commit f89f6167cc
290 changed files with 34563 additions and 3528 deletions

View File

@@ -1,19 +1,19 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../auth/storage/secure_token_storage.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Interceptor pour gérer l'authentification automatique
@singleton
class AuthInterceptor extends Interceptor {
final SecureTokenStorage _tokenStorage;
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
// Callback pour déclencher le refresh token
void Function()? onTokenRefreshNeeded;
// Callback pour déconnecter l'utilisateur
void Function()? onAuthenticationFailed;
AuthInterceptor(this._tokenStorage);
AuthInterceptor();
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
@@ -25,21 +25,13 @@ class AuthInterceptor extends Interceptor {
try {
// Récupérer le token d'accès
final accessToken = await _tokenStorage.getAccessToken();
final accessToken = await _secureStorage.read(key: 'access_token');
if (accessToken != null) {
// Vérifier si le token expire bientôt
final isExpiringSoon = await _tokenStorage.isAccessTokenExpiringSoon();
if (isExpiringSoon) {
// Déclencher le refresh token si nécessaire
onTokenRefreshNeeded?.call();
}
// Ajouter le token à l'en-tête Authorization
options.headers['Authorization'] = 'Bearer $accessToken';
}
handler.next(options);
} catch (e) {
// En cas d'erreur, continuer sans token
@@ -69,39 +61,16 @@ class AuthInterceptor extends Interceptor {
/// Gère les erreurs 401 (Non autorisé)
Future<void> _handle401Error(DioException err, ErrorInterceptorHandler handler) async {
try {
// Vérifier si on a un refresh token valide
final refreshToken = await _tokenStorage.getRefreshToken();
final refreshExpiresAt = await _tokenStorage.getRefreshTokenExpirationDate();
if (refreshToken != null &&
refreshExpiresAt != null &&
DateTime.now().isBefore(refreshExpiresAt)) {
// Tentative de refresh du token
onTokenRefreshNeeded?.call();
// Attendre un peu pour laisser le temps au refresh
await Future.delayed(const Duration(milliseconds: 100));
// Retry de la requête originale avec le nouveau token
final newAccessToken = await _tokenStorage.getAccessToken();
if (newAccessToken != null) {
final newRequest = await _retryRequest(err.requestOptions, newAccessToken);
handler.resolve(newRequest);
return;
}
}
// Si le refresh n'est pas possible ou a échoué, déconnecter l'utilisateur
await _tokenStorage.clearAuthData();
// Déclencher la déconnexion automatique
onAuthenticationFailed?.call();
// Nettoyer les tokens
await _secureStorage.deleteAll();
} catch (e) {
print('Erreur lors de la gestion de l\'erreur 401: $e');
await _tokenStorage.clearAuthData();
onAuthenticationFailed?.call();
}
handler.next(err);
}
@@ -113,28 +82,7 @@ class AuthInterceptor extends Interceptor {
handler.next(err);
}
/// Retry une requête avec un nouveau token
Future<Response> _retryRequest(RequestOptions options, String newAccessToken) async {
final dio = Dio();
// Copier les options originales
final newOptions = Options(
method: options.method,
headers: {
...options.headers,
'Authorization': 'Bearer $newAccessToken',
},
extra: {'skipAuth': true}, // Éviter la récursion infinie
);
// Effectuer la nouvelle requête
return await dio.request(
options.path,
data: options.data,
queryParameters: options.queryParameters,
options: newOptions,
);
}
/// Détermine si l'authentification doit être ignorée pour une requête
bool _shouldSkipAuth(RequestOptions options) {

View File

@@ -19,7 +19,7 @@ class DioClient {
void _configureOptions() {
_dio.options = BaseOptions(
// URL de base de l'API
baseUrl: 'http://192.168.1.13:8080', // Adresse de votre API Quarkus
baseUrl: 'http://192.168.1.11:8080', // Adresse de votre API Quarkus
// Timeouts
connectTimeout: const Duration(seconds: 30),