Flutter Integration
Flutter Integration
This guide covers how to integrate Skycloak authentication into Flutter applications using flutter_appauth and modern authentication patterns.
Prerequisites
- Flutter 3.19+ SDK (Flutter 3.24+ recommended)
- Dart 3.3+
- Skycloak cluster with configured realm and mobile client
- Android Studio / Xcode for platform-specific configuration
- Basic understanding of Flutter and Dart
⚠️
Flutter 3.22.0+ Breaking Change
If you’re using Flutter 3.22.0 or later, you may encounter authentication issues where the app opens a new browser window instead of returning to the app after login. This is due to a change in how Flutter handles task affinity.
Fix: Add the following to your android/app/src/main/AndroidManifest.xml inside the <activity> tag:
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:taskAffinity=""
...>See the flutter_appauth issue #430 for more details.
Quick Start
1. Add Dependencies
Update your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
# Authentication
flutter_appauth: ^7.0.1
flutter_secure_storage: ^9.2.2
# HTTP & JSON
http: ^1.2.2
dio: ^5.7.0
json_annotation: ^4.9.0
# State Management (choose one)
provider: ^6.1.2
riverpod: ^2.6.1
bloc: ^8.1.4
# Utilities
jwt_decoder: ^2.0.1
url_launcher: ^6.3.1
connectivity_plus: ^6.1.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.13
json_serializable: ^6.8.0
flutter_lints: ^4.0.02. Platform Configuration
Android Configuration
Update android/app/build.gradle:
android {
defaultConfig {
applicationId "com.yourcompany.app"
minSdkVersion 21
targetSdkVersion 33
manifestPlaceholders += [
'appAuthRedirectScheme': 'com.yourcompany.app'
]
}
}Update android/app/src/main/AndroidManifest.xml:
<manifest>
<application>
<!-- Your existing configuration -->
<!-- Add for AppAuth -->
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="com.yourcompany.app"/>
</intent-filter>
</activity>
</application>
</manifest>iOS Configuration
Update ios/Runner/Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.yourcompany.app</string>
</array>
<key>CFBundleURLName</key>
<string>com.yourcompany.app</string>
</dict>
</array>3. Create Auth Configuration
// lib/config/auth_config.dart
class AuthConfig {
static const String issuer = 'https://your-cluster-id.app.skycloak.io/realms/your-realm';
static const String clientId = 'your-flutter-app';
static const String redirectUrl = 'com.yourcompany.app://oauth/callback';
static const String postLogoutRedirectUrl = 'com.yourcompany.app://oauth/logout';
static const List<String> scopes = ['openid', 'profile', 'email', 'offline_access'];
// Discovery document URL
static const String discoveryUrl = '$issuer/.well-known/openid-configuration';
// Additional endpoints if needed
static const String authorizationEndpoint = '$issuer/protocol/openid-connect/auth';
static const String tokenEndpoint = '$issuer/protocol/openid-connect/token';
static const String userInfoEndpoint = '$issuer/protocol/openid-connect/userinfo';
static const String endSessionEndpoint = '$issuer/protocol/openid-connect/logout';
// PKCE is required for public clients
static const bool usePKCE = true;
// Prompt for login
static const Map<String, String> additionalParameters = {
'prompt': 'login',
};
}4. Create Auth Service
// lib/services/auth_service.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import '../config/auth_config.dart';
import '../models/user_info.dart';
class AuthService extends ChangeNotifier {
static final AuthService _instance = AuthService._internal();
factory AuthService() => _instance;
AuthService._internal();
final FlutterAppAuth _appAuth = const FlutterAppAuth();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
// Storage keys
static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _idTokenKey = 'id_token';
// Auth state
bool _isAuthenticated = false;
UserInfo? _userInfo;
String? _error;
bool _isLoading = false;
// Getters
bool get isAuthenticated => _isAuthenticated;
UserInfo? get userInfo => _userInfo;
String? get error => _error;
bool get isLoading => _isLoading;
// Initialize auth state
Future<void> init() async {
_setLoading(true);
try {
final accessToken = await _secureStorage.read(key: _accessTokenKey);
if (accessToken != null && !JwtDecoder.isExpired(accessToken)) {
final idToken = await _secureStorage.read(key: _idTokenKey);
if (idToken != null) {
_userInfo = _parseUserInfo(idToken);
_isAuthenticated = true;
}
} else if (accessToken != null) {
// Token expired, try to refresh
await _refreshToken();
}
} catch (e) {
debugPrint('Failed to initialize auth: $e');
} finally {
_setLoading(false);
}
}
// Login
Future<void> login() async {
_setLoading(true);
_clearError();
try {
final AuthorizationTokenResponse? result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
AuthConfig.clientId,
AuthConfig.redirectUrl,
serviceConfiguration: const AuthorizationServiceConfiguration(
authorizationEndpoint: AuthConfig.authorizationEndpoint,
tokenEndpoint: AuthConfig.tokenEndpoint,
endSessionEndpoint: AuthConfig.endSessionEndpoint,
),
scopes: AuthConfig.scopes,
promptValues: ['login'],
additionalParameters: AuthConfig.additionalParameters,
),
);
if (result != null) {
await _handleAuthResult(result);
}
} catch (e) {
_setError('Authentication failed: ${e.toString()}');
rethrow;
} finally {
_setLoading(false);
}
}
// Logout
Future<void> logout() async {
_setLoading(true);
try {
final idToken = await _secureStorage.read(key: _idTokenKey);
if (idToken != null) {
try {
await _appAuth.endSession(
EndSessionRequest(
idTokenHint: idToken,
postLogoutRedirectUrl: AuthConfig.postLogoutRedirectUrl,
serviceConfiguration: const AuthorizationServiceConfiguration(
authorizationEndpoint: AuthConfig.authorizationEndpoint,
tokenEndpoint: AuthConfig.tokenEndpoint,
endSessionEndpoint: AuthConfig.endSessionEndpoint,
),
),
);
} catch (e) {
debugPrint('End session failed: $e');
}
}
} finally {
await _clearAuthState();
_setLoading(false);
}
}
// Get access token
Future<String?> getAccessToken() async {
final token = await _secureStorage.read(key: _accessTokenKey);
if (token != null && JwtDecoder.isExpired(token)) {
// Token expired, refresh it
await _refreshToken();
return await _secureStorage.read(key: _accessTokenKey);
}
return token;
}
// Refresh token
Future<void> _refreshToken() async {
final refreshToken = await _secureStorage.read(key: _refreshTokenKey);
if (refreshToken == null) {
await _clearAuthState();
return;
}
try {
final TokenResponse? result = await _appAuth.token(
TokenRequest(
AuthConfig.clientId,
AuthConfig.redirectUrl,
serviceConfiguration: const AuthorizationServiceConfiguration(
authorizationEndpoint: AuthConfig.authorizationEndpoint,
tokenEndpoint: AuthConfig.tokenEndpoint,
),
refreshToken: refreshToken,
grantType: 'refresh_token',
),
);
if (result != null) {
await _handleTokenResponse(result);
}
} catch (e) {
debugPrint('Token refresh failed: $e');
await _clearAuthState();
}
}
// Handle auth result
Future<void> _handleAuthResult(AuthorizationTokenResponse result) async {
await _secureStorage.write(key: _accessTokenKey, value: result.accessToken!);
if (result.refreshToken != null) {
await _secureStorage.write(key: _refreshTokenKey, value: result.refreshToken!);
}
if (result.idToken != null) {
await _secureStorage.write(key: _idTokenKey, value: result.idToken!);
_userInfo = _parseUserInfo(result.idToken!);
}
_isAuthenticated = true;
notifyListeners();
}
// Handle token response
Future<void> _handleTokenResponse(TokenResponse result) async {
await _secureStorage.write(key: _accessTokenKey, value: result.accessToken!);
if (result.refreshToken != null) {
await _secureStorage.write(key: _refreshTokenKey, value: result.refreshToken!);
}
if (result.idToken != null) {
await _secureStorage.write(key: _idTokenKey, value: result.idToken!);
_userInfo = _parseUserInfo(result.idToken!);
}
_isAuthenticated = true;
notifyListeners();
}
// Parse user info from ID token
UserInfo _parseUserInfo(String idToken) {
final Map<String, dynamic> decodedToken = JwtDecoder.decode(idToken);
// Extract roles
final List<String> realmRoles =
(decodedToken['realm_access']?['roles'] as List<dynamic>?)
?.cast<String>() ?? [];
final Map<String, dynamic> resourceAccess =
decodedToken['resource_access'] as Map<String, dynamic>? ?? {};
final List<String> clientRoles =
(resourceAccess[AuthConfig.clientId]?['roles'] as List<dynamic>?)
?.cast<String>() ?? [];
return UserInfo(
id: decodedToken['sub'] as String,
username: decodedToken['preferred_username'] as String? ?? '',
email: decodedToken['email'] as String? ?? '',
name: decodedToken['name'] as String? ?? '',
roles: [...realmRoles, ...clientRoles],
groups: (decodedToken['groups'] as List<dynamic>?)?.cast<String>() ?? [],
);
}
// Clear auth state
Future<void> _clearAuthState() async {
await _secureStorage.deleteAll();
_isAuthenticated = false;
_userInfo = null;
notifyListeners();
}
// Role management
bool hasRole(String role) {
return _userInfo?.roles.contains(role) ?? false;
}
bool hasAnyRole(List<String> roles) {
return roles.any((role) => hasRole(role));
}
bool hasAllRoles(List<String> roles) {
return roles.every((role) => hasRole(role));
}
bool inGroup(String group) {
return _userInfo?.groups.contains(group) ?? false;
}
// Helpers
void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
void _setError(String error) {
_error = error;
notifyListeners();
}
void _clearError() {
_error = null;
notifyListeners();
}
}
// lib/models/user_info.dart
class UserInfo {
final String id;
final String username;
final String email;
final String name;
final List<String> roles;
final List<String> groups;
UserInfo({
required this.id,
required this.username,
required this.email,
required this.name,
required this.roles,
required this.groups,
});
Map<String, dynamic> toJson() => {
'id': id,
'username': username,
'email': email,
'name': name,
'roles': roles,
'groups': groups,
};
}5. Create Auth Provider (Provider Package)
// lib/providers/auth_provider.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
class AuthProvider extends StatefulWidget {
final Widget child;
const AuthProvider({Key? key, required this.child}) : super(key: key);
@override
State<AuthProvider> createState() => _AuthProviderState();
}
class _AuthProviderState extends State<AuthProvider> {
final AuthService _authService = AuthService();
@override
void initState() {
super.initState();
_authService.init();
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<AuthService>.value(
value: _authService,
child: widget.child,
);
}
}
// Extension for easy access
extension AuthExtension on BuildContext {
AuthService get auth => Provider.of<AuthService>(this, listen: false);
AuthService get authWatch => Provider.of<AuthService>(this);
}UI Implementation
Login Screen
// lib/screens/login_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
class LoginScreen extends StatelessWidget {
const LoginScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final authService = context.watch<AuthService>();
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF4c669f), Color(0xFF3b5998), Color(0xFF192f6a)],
),
),
child: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(40.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.lock_outline,
size: 80,
color: Colors.white,
),
const SizedBox(height: 20),
const Text(
'Welcome to MyApp',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 10),
const Text(
'Secure Authentication with Skycloak',
style: TextStyle(
fontSize: 16,
color: Colors.white70,
),
),
const SizedBox(height: 60),
if (authService.error != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withOpacity(0.3)),
),
child: Text(
authService.error!,
style: const TextStyle(color: Colors.white),
),
),
const SizedBox(height: 20),
],
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: authService.isLoading
? null
: () => _handleLogin(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF3b5998),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
),
child: authService.isLoading
? const CircularProgressIndicator()
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(Icons.login),
SizedBox(width: 8),
Text(
'Sign In with Skycloak',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
const SizedBox(height: 20),
TextButton(
onPressed: authService.isLoading
? null
: () => _handleRegister(context),
child: const Text(
'Create Account',
style: TextStyle(
color: Colors.white,
fontSize: 16,
decoration: TextDecoration.underline,
),
),
),
],
),
),
),
),
),
);
}
Future<void> _handleLogin(BuildContext context) async {
try {
await context.read<AuthService>().login();
} catch (e) {
// Error handling is done in AuthService
}
}
Future<void> _handleRegister(BuildContext context) async {
// Registration uses the same flow with different parameters
_handleLogin(context);
}
}Main App with Navigation
// lib/main.dart
import 'package:flutter/material.dart';
import 'providers/auth_provider.dart';
import 'screens/login_screen.dart';
import 'screens/home_screen.dart';
import 'services/auth_service.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return AuthProvider(
child: MaterialApp(
title: 'Flutter Skycloak App',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const AuthWrapper(),
),
);
}
}
class AuthWrapper extends StatelessWidget {
const AuthWrapper({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final authService = context.watch<AuthService>();
if (authService.isLoading && !authService.isAuthenticated) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
return authService.isAuthenticated
? const HomeScreen()
: const LoginScreen();
}
}Home Screen with Tab Navigation
// lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
import '../widgets/protected_widget.dart';
import 'dashboard_tab.dart';
import 'profile_tab.dart';
import 'admin_tab.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
final authService = context.watch<AuthService>();
final List<Widget> tabs = [
const DashboardTab(),
const ProfileTab(),
if (authService.hasRole('admin')) const AdminTab(),
];
return Scaffold(
appBar: AppBar(
title: const Text('MyApp'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => _handleLogout(context),
),
],
),
body: IndexedStack(
index: _currentIndex,
children: tabs,
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) => setState(() => _currentIndex = index),
items: [
const BottomNavigationBarItem(
icon: Icon(Icons.dashboard),
label: 'Dashboard',
),
const BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profile',
),
if (authService.hasRole('admin'))
const BottomNavigationBarItem(
icon: Icon(Icons.admin_panel_settings),
label: 'Admin',
),
],
),
);
}
Future<void> _handleLogout(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Logout'),
content: const Text('Are you sure you want to logout?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Logout'),
),
],
),
);
if (confirmed == true && mounted) {
await context.read<AuthService>().logout();
}
}
}
// lib/screens/dashboard_tab.dart
class DashboardTab extends StatelessWidget {
const DashboardTab({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final userInfo = context.watch<AuthService>().userInfo;
return RefreshIndicator(
onRefresh: () async {
// Refresh dashboard data
},
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Welcome, ${userInfo?.name ?? 'User'}!',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 20),
_buildCard(
title: 'Your Dashboard',
description: 'View your personalized content and metrics',
icon: Icons.dashboard,
),
const SizedBox(height: 16),
ProtectedWidget(
roles: const ['admin', 'manager'],
child: _buildCard(
title: 'Management Tools',
description: 'Access administrative features',
icon: Icons.admin_panel_settings,
color: Colors.orange,
),
),
const SizedBox(height: 16),
ProtectedWidget(
roles: const ['editor', 'moderator'],
child: _buildCard(
title: 'Content Management',
description: 'Create and manage content',
icon: Icons.edit,
color: Colors.green,
),
),
],
),
);
}
Widget _buildCard({
required String title,
required String description,
required IconData icon,
Color color = Colors.blue,
}) {
return Card(
elevation: 4,
child: ListTile(
leading: CircleAvatar(
backgroundColor: color.withOpacity(0.1),
child: Icon(icon, color: color),
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(description),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
// Navigate to specific feature
},
),
);
}
}Protected Widget
// lib/widgets/protected_widget.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
class ProtectedWidget extends StatelessWidget {
final Widget child;
final List<String> roles;
final bool requireAll;
final Widget? fallback;
const ProtectedWidget({
Key? key,
required this.child,
this.roles = const [],
this.requireAll = false,
this.fallback,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final authService = context.watch<AuthService>();
if (roles.isEmpty) {
return child;
}
final hasAccess = requireAll
? authService.hasAllRoles(roles)
: authService.hasAnyRole(roles);
if (hasAccess) {
return child;
}
return fallback ?? const SizedBox.shrink();
}
}API Integration
HTTP Client with Authentication
// lib/services/api_service.dart
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'auth_service.dart';
class ApiService {
static final ApiService _instance = ApiService._internal();
factory ApiService() => _instance;
ApiService._internal();
late final Dio _dio;
final AuthService _authService = AuthService();
static const String baseUrl = 'https://api.example.com';
void init() {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
},
));
// Add interceptors
_dio.interceptors.add(AuthInterceptor(_authService));
if (kDebugMode) {
_dio.interceptors.add(LogInterceptor(
requestBody: true,
responseBody: true,
));
}
}
// Generic request method
Future<T> request<T>({
required String path,
required String method,
Map<String, dynamic>? data,
Map<String, dynamic>? queryParameters,
Options? options,
T Function(dynamic)? parser,
}) async {
try {
final response = await _dio.request(
path,
data: data,
queryParameters: queryParameters,
options: Options(
method: method,
headers: options?.headers,
),
);
if (parser != null) {
return parser(response.data);
}
return response.data as T;
} on DioException catch (e) {
throw _handleError(e);
}
}
// Convenience methods
Future<T> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
T Function(dynamic)? parser,
}) async {
return request<T>(
path: path,
method: 'GET',
queryParameters: queryParameters,
parser: parser,
);
}
Future<T> post<T>(
String path, {
Map<String, dynamic>? data,
T Function(dynamic)? parser,
}) async {
return request<T>(
path: path,
method: 'POST',
data: data,
parser: parser,
);
}
Future<T> put<T>(
String path, {
Map<String, dynamic>? data,
T Function(dynamic)? parser,
}) async {
return request<T>(
path: path,
method: 'PUT',
data: data,
parser: parser,
);
}
Future<T> delete<T>(
String path, {
T Function(dynamic)? parser,
}) async {
return request<T>(
path: path,
method: 'DELETE',
parser: parser,
);
}
// Error handling
Exception _handleError(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return TimeoutException('Connection timeout');
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
final message = error.response?.data['message'] ?? error.message;
switch (statusCode) {
case 401:
return UnauthorizedException(message);
case 403:
return ForbiddenException(message);
case 404:
return NotFoundException(message);
case 500:
return ServerException(message);
default:
return ApiException(message, statusCode: statusCode);
}
case DioExceptionType.cancel:
return CancelledException('Request cancelled');
default:
return NetworkException('Network error: ${error.message}');
}
}
}
// Auth interceptor
class AuthInterceptor extends Interceptor {
final AuthService _authService;
AuthInterceptor(this._authService);
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
// Add auth token if available
final token = await _authService.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
// Handle 401 errors
if (err.response?.statusCode == 401) {
// Try to refresh token
try {
await _authService.refreshToken();
// Retry the request
final token = await _authService.getAccessToken();
if (token != null) {
err.requestOptions.headers['Authorization'] = 'Bearer $token';
final response = await Dio().fetch(err.requestOptions);
handler.resolve(response);
return;
}
} catch (e) {
// Refresh failed, logout user
await _authService.logout();
}
}
handler.next(err);
}
}
// Custom exceptions
class ApiException implements Exception {
final String message;
final int? statusCode;
ApiException(this.message, {this.statusCode});
@override
String toString() => 'ApiException: $message';
}
class NetworkException extends ApiException {
NetworkException(String message) : super(message);
}
class TimeoutException extends ApiException {
TimeoutException(String message) : super(message);
}
class UnauthorizedException extends ApiException {
UnauthorizedException(String message) : super(message, statusCode: 401);
}
class ForbiddenException extends ApiException {
ForbiddenException(String message) : super(message, statusCode: 403);
}
class NotFoundException extends ApiException {
NotFoundException(String message) : super(message, statusCode: 404);
}
class ServerException extends ApiException {
ServerException(String message) : super(message, statusCode: 500);
}
class CancelledException extends ApiException {
CancelledException(String message) : super(message);
}Repository Pattern
// lib/repositories/user_repository.dart
import '../services/api_service.dart';
import '../models/user_profile.dart';
class UserRepository {
final ApiService _apiService = ApiService();
Future<UserProfile> getProfile() async {
final data = await _apiService.get<Map<String, dynamic>>(
'/user/profile',
parser: (json) => json as Map<String, dynamic>,
);
return UserProfile.fromJson(data);
}
Future<UserProfile> updateProfile(UserProfile profile) async {
final data = await _apiService.put<Map<String, dynamic>>(
'/user/profile',
data: profile.toJson(),
parser: (json) => json as Map<String, dynamic>,
);
return UserProfile.fromJson(data);
}
Future<List<UserProfile>> getUsers({int page = 1}) async {
final data = await _apiService.get<Map<String, dynamic>>(
'/admin/users',
queryParameters: {'page': page},
parser: (json) => json as Map<String, dynamic>,
);
final users = (data['users'] as List<dynamic>)
.map((json) => UserProfile.fromJson(json as Map<String, dynamic>))
.toList();
return users;
}
Future<void> deleteUser(String userId) async {
await _apiService.delete('/admin/users/$userId');
}
}
// lib/models/user_profile.dart
import 'package:json_annotation/json_annotation.dart';
part 'user_profile.g.dart';
@JsonSerializable()
class UserProfile {
final String id;
final String username;
final String email;
final String name;
final String? avatar;
final DateTime createdAt;
final DateTime updatedAt;
UserProfile({
required this.id,
required this.username,
required this.email,
required this.name,
this.avatar,
required this.createdAt,
required this.updatedAt,
});
factory UserProfile.fromJson(Map<String, dynamic> json) =>
_$UserProfileFromJson(json);
Map<String, dynamic> toJson() => _$UserProfileToJson(this);
}State Management with Riverpod
// lib/providers/providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/auth_service.dart';
import '../services/api_service.dart';
import '../repositories/user_repository.dart';
// Auth service provider
final authServiceProvider = ChangeNotifierProvider<AuthService>((ref) {
return AuthService()..init();
});
// API service provider
final apiServiceProvider = Provider<ApiService>((ref) {
return ApiService()..init();
});
// User repository provider
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository();
});
// User profile provider
final userProfileProvider = FutureProvider.autoDispose<UserProfile?>((ref) async {
final auth = ref.watch(authServiceProvider);
if (!auth.isAuthenticated) {
return null;
}
final repository = ref.read(userRepositoryProvider);
return repository.getProfile();
});
// Users list provider (admin)
final usersListProvider = FutureProvider.family<List<UserProfile>, int>(
(ref, page) async {
final repository = ref.read(userRepositoryProvider);
return repository.getUsers(page: page);
},
);Testing
Unit Tests
// test/services/auth_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:your_app/services/auth_service.dart';
@GenerateMocks([FlutterAppAuth, FlutterSecureStorage])
import 'auth_service_test.mocks.dart';
void main() {
group('AuthService', () {
late AuthService authService;
late MockFlutterAppAuth mockAppAuth;
late MockFlutterSecureStorage mockSecureStorage;
setUp(() {
mockAppAuth = MockFlutterAppAuth();
mockSecureStorage = MockFlutterSecureStorage();
// Create auth service with mocked dependencies
authService = AuthService();
// Inject mocks (you'll need to modify AuthService to accept these)
});
test('login should store tokens on success', () async {
// Arrange
const mockResult = AuthorizationTokenResponse(
'access_token',
'refresh_token',
DateTime.now(),
'id_token',
'token_type',
{},
);
when(mockAppAuth.authorizeAndExchangeCode(any))
.thenAnswer((_) async => mockResult);
// Act
await authService.login();
// Assert
expect(authService.isAuthenticated, true);
verify(mockSecureStorage.write(
key: 'access_token',
value: 'access_token',
)).called(1);
});
test('hasRole should check user roles correctly', () {
// Arrange
authService.userInfo = UserInfo(
id: '123',
username: 'testuser',
email: '[email protected]',
name: 'Test User',
roles: ['user', 'admin'],
groups: [],
);
// Act & Assert
expect(authService.hasRole('admin'), true);
expect(authService.hasRole('superadmin'), false);
});
test('hasAnyRole should return true if user has any role', () {
// Arrange
authService.userInfo = UserInfo(
id: '123',
username: 'testuser',
email: '[email protected]',
name: 'Test User',
roles: ['editor'],
groups: [],
);
// Act & Assert
expect(authService.hasAnyRole(['admin', 'editor']), true);
expect(authService.hasAnyRole(['admin', 'manager']), false);
});
});
}Widget Tests
// test/widgets/protected_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:your_app/widgets/protected_widget.dart';
import 'package:your_app/services/auth_service.dart';
void main() {
group('ProtectedWidget', () {
late AuthService mockAuthService;
setUp(() {
mockAuthService = AuthService();
});
Widget createTestWidget(Widget child) {
return MaterialApp(
home: ChangeNotifierProvider<AuthService>.value(
value: mockAuthService,
child: Scaffold(body: child),
),
);
}
testWidgets('shows child when user has required role', (tester) async {
// Arrange
mockAuthService.userInfo = UserInfo(
id: '123',
username: 'testuser',
email: '[email protected]',
name: 'Test User',
roles: ['admin'],
groups: [],
);
// Act
await tester.pumpWidget(
createTestWidget(
const ProtectedWidget(
roles: ['admin'],
child: Text('Protected Content'),
),
),
);
// Assert
expect(find.text('Protected Content'), findsOneWidget);
});
testWidgets('hides child when user lacks required role', (tester) async {
// Arrange
mockAuthService.userInfo = UserInfo(
id: '123',
username: 'testuser',
email: '[email protected]',
name: 'Test User',
roles: ['user'],
groups: [],
);
// Act
await tester.pumpWidget(
createTestWidget(
const ProtectedWidget(
roles: ['admin'],
child: Text('Protected Content'),
),
),
);
// Assert
expect(find.text('Protected Content'), findsNothing);
});
testWidgets('shows fallback when user lacks required role', (tester) async {
// Arrange
mockAuthService.userInfo = UserInfo(
id: '123',
username: 'testuser',
email: '[email protected]',
name: 'Test User',
roles: ['user'],
groups: [],
);
// Act
await tester.pumpWidget(
createTestWidget(
const ProtectedWidget(
roles: ['admin'],
fallback: Text('Access Denied'),
child: Text('Protected Content'),
),
),
);
// Assert
expect(find.text('Protected Content'), findsNothing);
expect(find.text('Access Denied'), findsOneWidget);
});
});
}Integration Tests
// integration_test/auth_flow_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:your_app/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Authentication Flow', () {
testWidgets('shows login screen when not authenticated', (tester) async {
// Start app
app.main();
await tester.pumpAndSettle();
// Verify login screen is shown
expect(find.text('Sign In with Skycloak'), findsOneWidget);
expect(find.text('Welcome to MyApp'), findsOneWidget);
});
testWidgets('can tap login button', (tester) async {
// Start app
app.main();
await tester.pumpAndSettle();
// Tap login button
await tester.tap(find.text('Sign In with Skycloak'));
await tester.pumpAndSettle();
// Note: Actual OAuth flow will open external browser
// For integration tests, you might want to mock the auth service
});
});
}Production Considerations
Security Configuration
// lib/utils/security_utils.dart
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_jailbreak_detection/flutter_jailbreak_detection.dart';
class SecurityUtils {
// Check if device is compromised
static Future<bool> isDeviceSecure() async {
if (kDebugMode) {
// Skip security checks in debug mode
return true;
}
try {
// Check for jailbreak/root
final isJailbroken = await FlutterJailbreakDetection.jailbroken;
if (isJailbroken) {
debugPrint('Device is jailbroken/rooted');
return false;
}
// Check for development mode (Android)
if (Platform.isAndroid) {
final isDevelopmentMode = await FlutterJailbreakDetection.developerMode;
if (isDevelopmentMode) {
debugPrint('Development mode is enabled');
return false;
}
}
return true;
} catch (e) {
debugPrint('Security check failed: $e');
return false;
}
}
// Certificate pinning configuration
static SecurityContext createSecurityContext() {
final context = SecurityContext(withTrustedRoots: false);
// Add your certificate
context.setTrustedCertificatesBytes(yourCertificateBytes);
return context;
}
// Obfuscate sensitive strings
static String obfuscate(String input) {
// Simple obfuscation - use more sophisticated methods in production
return base64Encode(utf8.encode(input));
}
static String deobfuscate(String input) {
return utf8.decode(base64Decode(input));
}
}
// lib/utils/app_config.dart
class AppConfig {
static const bool enableCertificatePinning = bool.fromEnvironment(
'ENABLE_CERT_PINNING',
defaultValue: true,
);
static const bool enableRootDetection = bool.fromEnvironment(
'ENABLE_ROOT_DETECTION',
defaultValue: true,
);
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.example.com',
);
static const int sessionTimeout = int.fromEnvironment(
'SESSION_TIMEOUT',
defaultValue: 1800, // 30 minutes
);
}Performance Optimization
// lib/utils/performance_utils.dart
import 'package:flutter/foundation.dart';
import 'package:cached_network_image/cached_network_image.dart';
class PerformanceUtils {
// Debounce function calls
static Function debounce(Function func, Duration delay) {
Timer? timer;
return () {
timer?.cancel();
timer = Timer(delay, () => func());
};
}
// Throttle function calls
static Function throttle(Function func, Duration delay) {
bool isThrottled = false;
return () {
if (!isThrottled) {
func();
isThrottled = true;
Timer(delay, () => isThrottled = false);
}
};
}
// Cached image widget
static Widget cachedImage(String imageUrl, {
double? width,
double? height,
BoxFit? fit,
}) {
return CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit ?? BoxFit.cover,
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => const Icon(Icons.error),
cacheKey: imageUrl,
memCacheWidth: width?.toInt(),
memCacheHeight: height?.toInt(),
);
}
// Performance monitoring
static T measurePerformance<T>(String name, T Function() operation) {
final stopwatch = Stopwatch()..start();
try {
final result = operation();
stopwatch.stop();
if (kDebugMode) {
debugPrint('[Performance] $name: ${stopwatch.elapsedMilliseconds}ms');
}
return result;
} catch (e) {
stopwatch.stop();
debugPrint('[Performance] $name failed after ${stopwatch.elapsedMilliseconds}ms');
rethrow;
}
}
}
// Widget performance optimization
mixin AutomaticKeepAliveClientMixin<T extends StatefulWidget> on State<T> {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return buildWidget(context);
}
Widget buildWidget(BuildContext context);
}Error Handling and Logging
// lib/utils/error_handler.dart
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class ErrorHandler {
static Future<void> init() async {
// Initialize Sentry for production error tracking
await SentryFlutter.init(
(options) {
options.dsn = 'YOUR_SENTRY_DSN';
options.environment = kReleaseMode ? 'production' : 'development';
options.tracesSampleRate = 1.0;
},
);
// Set up Flutter error handling
FlutterError.onError = (FlutterErrorDetails details) {
if (kReleaseMode) {
// Send to Sentry in production
Sentry.captureException(
details.exception,
stackTrace: details.stack,
);
} else {
// Log to console in development
FlutterError.dumpErrorToConsole(details);
}
};
// Catch async errors
PlatformDispatcher.instance.onError = (error, stack) {
if (kReleaseMode) {
Sentry.captureException(error, stackTrace: stack);
} else {
debugPrint('Async error: $error\n$stack');
}
return true;
};
}
// Log custom events
static void logEvent(String message, {
Map<String, dynamic>? extra,
SentryLevel? level,
}) {
if (kReleaseMode) {
Sentry.captureMessage(
message,
level: level ?? SentryLevel.info,
withScope: (scope) {
extra?.forEach((key, value) {
scope.setExtra(key, value);
});
},
);
} else {
debugPrint('[${level ?? 'INFO'}] $message ${extra ?? ''}');
}
}
// Show user-friendly error dialog
static void showErrorDialog(BuildContext context, String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Error'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
}Troubleshooting
Common Issues
-
Redirect URL Mismatch
- Verify scheme in AndroidManifest.xml and Info.plist
- Ensure no trailing slashes in redirect URLs
- Check for case sensitivity
-
Token Storage Issues
- iOS: Enable Keychain sharing in capabilities
- Android: Ensure proper ProGuard rules for flutter_secure_storage
- Test on real devices, not just emulators
-
Deep Link Not Working
- iOS: Check Associated Domains configuration
- Android: Verify intent-filter configuration
- Use
flutter doctorto check for issues
Debug Utilities
// lib/utils/debug_utils.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class DebugUtils {
// Enable debug mode features
static void enableDebugMode() {
if (kDebugMode) {
// Show performance overlay
debugPrintRebuildDirtyWidgets = true;
// Log all HTTP requests
HttpClient.enableTimelineLogging = true;
// Show material grid
debugPaintSizeEnabled = false;
// Show semantic labels
debugShowCheckedModeBanner = true;
}
}
// Test deep links
static Future<void> testDeepLink(String url) async {
try {
final uri = Uri.parse(url);
debugPrint('Testing deep link: $url');
// Check if URL can be launched
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
debugPrint('Deep link launched successfully');
} else {
debugPrint('Cannot launch deep link');
}
} catch (e) {
debugPrint('Deep link test failed: $e');
}
}
// Log auth state
static void logAuthState(AuthService authService) {
debugPrint('=== Auth State ===');
debugPrint('Authenticated: ${authService.isAuthenticated}');
debugPrint('User: ${authService.userInfo?.toJson()}');
debugPrint('Error: ${authService.error}');
}
// Copy text to clipboard (useful for tokens)
static Future<void> copyToClipboard(String text, BuildContext context) async {
await Clipboard.setData(ClipboardData(text: text));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied to clipboard')),
);
}
}
}
// Debug console widget
class DebugConsole extends StatelessWidget {
final AuthService authService;
const DebugConsole({Key? key, required this.authService}) : super(key: key);
@override
Widget build(BuildContext context) {
if (!kDebugMode) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(8),
color: Colors.black87,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Debug Console',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
Text(
'Auth: ${authService.isAuthenticated}',
style: TextStyle(color: Colors.white, fontSize: 12),
),
Text(
'User: ${authService.userInfo?.username ?? 'N/A'}',
style: TextStyle(color: Colors.white, fontSize: 12),
),
Text(
'Roles: ${authService.userInfo?.roles.join(', ') ?? 'N/A'}',
style: TextStyle(color: Colors.white, fontSize: 12),
),
],
),
);
}
}Next Steps
- Configure Security Settings - Add extra security layers including MFA options
- Set Up Applications - Configure your Flutter app client settings
- User Management - Manage users and their access
- Explore Other Integrations - See integration guides for other platforms