Skip to main content

Authentication & Security Implementation Guide

This guide provides comprehensive documentation for implementing secure authentication in OpenLift client applications, covering JWT tokens, HMAC signing, token refresh, and security best practices.

⚠️ CRITICAL: THIN CLIENT SECURITY IMPLEMENTATION ONLY

What Client Applications SHOULD do:

  • ✅ Store tokens securely using platform-appropriate methods
  • ✅ Include authentication headers in GraphQL requests
  • ✅ Handle token refresh automatically
  • ✅ Implement HMAC request signing for production
  • ✅ Validate tokens client-side for UX optimization

What Client Applications MUST NOT do:

  • ❌ Implement authentication logic or user validation
  • ❌ Generate tokens or perform cryptographic operations
  • ❌ Store passwords or sensitive credentials
  • ❌ Make authorization decisions based on local logic
  • ❌ Bypass server-side authentication checks

Authentication Overview

OpenLift uses a dual-token JWT authentication system with optional HMAC request signing for production environments:

Token Types

  • Access Tokens: Short-lived (15 minutes), used for API requests
  • Refresh Tokens: Long-lived (7 days), stored securely, used to obtain new access tokens
  • HMAC Signatures: Production-only request integrity verification

Security Architecture

┌─────────────┐    JWT + HMAC     ┌─────────────┐
│ Client │ ────────────────→ │ Server │
│ │ │ │
│ • Token │ │ • Validate │
│ Storage │ │ JWT │
│ • HMAC │ │ • Verify │
│ Signing │ │ HMAC │
│ • Auto │ │ • Check │
│ Refresh │ │ Perms │
└─────────────┘ └─────────────┘

1. Authentication Flow Implementation

Flutter Authentication Service

// lib/services/auth_service.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:crypto/crypto.dart';
import 'dart:convert';
import 'dart:typed_data';

class AuthService {
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: IOSAccessibility.first_unlock_this_device,
),
);

static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userIdKey = 'user_id';
static const String _hmacKeyKey = 'hmac_key';

// Authentication GraphQL Operations
static const String _loginMutation = '''
mutation LoginUser(\$input: LoginUserInput!) {
loginUser(input: \$input) {
accessToken
refreshToken
user {
id
email
username
displayName
fitnessLevel
unitPreferences {
weightUnit
distanceUnit
temperatureUnit
}
notifications {
workoutReminders
progressUpdates
socialNotifications
}
}
}
}
''';

static const String _registerMutation = '''
mutation RegisterUser(\$input: RegisterUserInput!) {
registerUser(input: \$input) {
accessToken
refreshToken
user {
id
email
username
displayName
}
}
}
''';

static const String _refreshTokenMutation = '''
mutation RefreshToken(\$refreshToken: String!) {
refreshToken(refreshToken: \$refreshToken) {
accessToken
refreshToken
user {
id
email
username
displayName
}
}
}
''';

// ═══════════════════════════════════════════════════════════════════════════
// Authentication Operations
// ═══════════════════════════════════════════════════════════════════════════

/// Login with email and password
/// Returns AuthResult with user data or throws AuthException
Future<AuthResult> login(String email, String password) async {
try {
final client = await GraphQLClientService.getClient();

final result = await client.mutate(
MutationOptions(
document: gql(_loginMutation),
variables: {
'input': {
'email': email.toLowerCase().trim(),
'password': password,
}
},
),
);

if (result.hasException) {
throw AuthException(_parseAuthError(result.exception!));
}

final loginData = result.data!['loginUser'];
await _storeAuthData(loginData);

return AuthResult(
success: true,
user: User.fromJson(loginData['user']),
message: 'Login successful',
);
} catch (e) {
if (e is AuthException) rethrow;
throw AuthException('Login failed: ${e.toString()}');
}
}

/// Register new user account
/// Returns AuthResult with user data or throws AuthException
Future<AuthResult> register({
required String email,
required String username,
required String password,
required String displayName,
FitnessLevel? fitnessLevel,
UnitPreferences? unitPreferences,
}) async {
try {
final client = await GraphQLClientService.getClient();

final result = await client.mutate(
MutationOptions(
document: gql(_registerMutation),
variables: {
'input': {
'email': email.toLowerCase().trim(),
'username': username.trim(),
'password': password,
'displayName': displayName.trim(),
if (fitnessLevel != null) 'fitnessLevel': fitnessLevel.name.toUpperCase(),
if (unitPreferences != null) 'unitPreferences': {
'weightUnit': unitPreferences.weightUnit.name.toUpperCase(),
'distanceUnit': unitPreferences.distanceUnit.name.toUpperCase(),
'temperatureUnit': unitPreferences.temperatureUnit.name.toUpperCase(),
},
}
},
),
);

if (result.hasException) {
throw AuthException(_parseAuthError(result.exception!));
}

final registerData = result.data!['registerUser'];
await _storeAuthData(registerData);

return AuthResult(
success: true,
user: User.fromJson(registerData['user']),
message: 'Registration successful',
);
} catch (e) {
if (e is AuthException) rethrow;
throw AuthException('Registration failed: ${e.toString()}');
}
}

// ═══════════════════════════════════════════════════════════════════════════
// Token Management
// ═══════════════════════════════════════════════════════════════════════════

/// Get valid access token, refreshing if necessary
Future<String?> getValidAccessToken() async {
final token = await _storage.read(key: _accessTokenKey);
if (token == null) return null;

// Check if token is expired or will expire soon (within 1 minute)
if (_isTokenExpiredSoon(token, bufferSeconds: 60)) {
return await _refreshAccessToken();
}

return token;
}

/// Refresh access token using stored refresh token
Future<String?> _refreshAccessToken() async {
final refreshToken = await _storage.read(key: _refreshTokenKey);
if (refreshToken == null) {
await logout();
return null;
}

try {
final client = await GraphQLClientService.getClient();

final result = await client.mutate(
MutationOptions(
document: gql(_refreshTokenMutation),
variables: {'refreshToken': refreshToken},
),
);

if (result.hasException) {
// Refresh token is invalid, logout user
await logout();
throw AuthException('Session expired. Please login again.');
}

final tokenData = result.data!['refreshToken'];
await _storeAuthData(tokenData);

return tokenData['accessToken'];
} catch (e) {
await logout();
if (e is AuthException) rethrow;
throw AuthException('Session refresh failed: ${e.toString()}');
}
}

/// Check if user is currently authenticated with valid tokens
Future<bool> isAuthenticated() async {
final accessToken = await _storage.read(key: _accessTokenKey);
final refreshToken = await _storage.read(key: _refreshTokenKey);

if (accessToken == null || refreshToken == null) return false;

// If access token is valid, user is authenticated
if (!_isTokenExpiredSoon(accessToken, bufferSeconds: 0)) return true;

// Try to refresh if access token is expired but refresh token exists
final newToken = await _refreshAccessToken();
return newToken != null;
}

// ═══════════════════════════════════════════════════════════════════════════
// HMAC Request Signing (Production Only)
// ═══════════════════════════════════════════════════════════════════════════

/// Generate HMAC signature for GraphQL request (production only)
Future<Map<String, String>> generateHMACHeaders({
required String operation,
required Map<String, dynamic> variables,
required String userId,
}) async {
// Only generate HMAC in production builds
if (!AppConfig.isProduction) return {};

final hmacKey = await _storage.read(key: _hmacKeyKey);
if (hmacKey == null) {
throw AuthException('HMAC key not found. Please re-authenticate.');
}

final timestamp = DateTime.now().millisecondsSinceEpoch.toString();

// Create signature payload
final payload = {
'operation': operation,
'variables': variables,
'timestamp': timestamp,
'userId': userId,
};

final payloadString = json.encode(payload);
final key = utf8.encode(hmacKey);
final message = utf8.encode(payloadString);

final hmac = Hmac(sha256, key);
final signature = hmac.convert(message).toString();

return {
'X-Signature': signature,
'X-Timestamp': timestamp,
'X-User-ID': userId,
};
}

// ═══════════════════════════════════════════════════════════════════════════
// Session Management
// ═══════════════════════════════════════════════════════════════════════════

/// Get current authenticated user data
Future<User?> getCurrentUser() async {
if (!await isAuthenticated()) return null;

// User data is stored during authentication, retrieve from secure storage
final userData = await _storage.read(key: 'user_data');
if (userData == null) return null;

try {
final userMap = json.decode(userData) as Map<String, dynamic>;
return User.fromJson(userMap);
} catch (e) {
// Clear corrupted user data
await _storage.delete(key: 'user_data');
return null;
}
}

/// Logout user and clear all authentication data
Future<void> logout() async {
// Clear all stored authentication data
await Future.wait([
_storage.delete(key: _accessTokenKey),
_storage.delete(key: _refreshTokenKey),
_storage.delete(key: _userIdKey),
_storage.delete(key: _hmacKeyKey),
_storage.delete(key: 'user_data'),
]);

// Clear GraphQL cache
final client = await GraphQLClientService.getClient();
await client.cache.reset();

// Emit logout event for app-wide state updates
EventBus().emit('auth.logout');
}

// ═══════════════════════════════════════════════════════════════════════════
// Private Helper Methods
// ═══════════════════════════════════════════════════════════════════════════

/// Store authentication data securely
Future<void> _storeAuthData(Map<String, dynamic> authData) async {
await Future.wait([
_storage.write(key: _accessTokenKey, value: authData['accessToken']),
_storage.write(key: _refreshTokenKey, value: authData['refreshToken']),
_storage.write(key: _userIdKey, value: authData['user']['id']),
_storage.write(key: 'user_data', value: json.encode(authData['user'])),
// Store HMAC key if provided (production only)
if (authData['hmacKey'] != null)
_storage.write(key: _hmacKeyKey, value: authData['hmacKey']),
]);
}

/// Parse GraphQL authentication errors into user-friendly messages
String _parseAuthError(OperationException exception) {
if (exception.graphqlErrors.isNotEmpty) {
final error = exception.graphqlErrors.first;
final code = error.extensions?['code'];

switch (code) {
case 'INVALID_CREDENTIALS':
return 'Invalid email or password. Please check your credentials and try again.';
case 'USER_NOT_FOUND':
return 'No account found with this email address.';
case 'EMAIL_ALREADY_EXISTS':
return 'An account with this email already exists. Please use a different email or try logging in.';
case 'USERNAME_ALREADY_EXISTS':
return 'This username is already taken. Please choose a different username.';
case 'WEAK_PASSWORD':
return 'Password is too weak. Please use at least 8 characters with a mix of letters, numbers, and symbols.';
case 'INVALID_EMAIL_FORMAT':
return 'Please enter a valid email address.';
case 'ACCOUNT_LOCKED':
return 'Your account has been temporarily locked due to multiple failed login attempts. Please try again later.';
case 'RATE_LIMITED':
return 'Too many requests. Please wait a moment before trying again.';
case 'REFRESH_TOKEN_EXPIRED':
return 'Your session has expired. Please log in again.';
case 'INVALID_REFRESH_TOKEN':
return 'Invalid session. Please log in again.';
default:
return error.message.isNotEmpty ? error.message : 'Authentication failed. Please try again.';
}
}

// Handle network errors
if (exception.linkException != null) {
if (exception.linkException is NetworkException) {
return 'Network error. Please check your internet connection and try again.';
} else if (exception.linkException is ServerException) {
return 'Server error. Please try again in a few moments.';
}
}

return 'Authentication failed. Please check your connection and try again.';
}

/// Check if JWT token is expired or will expire soon
bool _isTokenExpiredSoon(String token, {required int bufferSeconds}) {
try {
final parts = token.split('.');
if (parts.length != 3) return true;

// Decode JWT payload
final payload = json.decode(
utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))
) as Map<String, dynamic>;

final exp = payload['exp'] as int?;
if (exp == null) return true;

final expirationTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000);
final now = DateTime.now();
final buffer = Duration(seconds: bufferSeconds);

return now.isAfter(expirationTime.subtract(buffer));
} catch (e) {
// If token cannot be parsed, consider it expired
return true;
}
}
}

// ═══════════════════════════════════════════════════════════════════════════
// Authentication Data Models
// ═══════════════════════════════════════════════════════════════════════════

class AuthResult {
final bool success;
final User? user;
final String message;
final String? errorCode;

const AuthResult({
required this.success,
this.user,
required this.message,
this.errorCode,
});

factory AuthResult.failure(String message, {String? errorCode}) {
return AuthResult(
success: false,
message: message,
errorCode: errorCode,
);
}
}

class AuthException implements Exception {
final String message;
final String? errorCode;

const AuthException(this.message, {this.errorCode});

@override
String toString() => 'AuthException: $message';
}

enum FitnessLevel {
beginner,
intermediate,
advanced,
elite,
}

class UnitPreferences {
final WeightUnit weightUnit;
final DistanceUnit distanceUnit;
final TemperatureUnit temperatureUnit;

const UnitPreferences({
required this.weightUnit,
required this.distanceUnit,
required this.temperatureUnit,
});
}

enum WeightUnit { kg, lb }
enum DistanceUnit { km, mile }
enum TemperatureUnit { celsius, fahrenheit }

2. GraphQL Client Authentication Integration

Enhanced GraphQL Client with Authentication

// lib/services/graphql_client_service.dart
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:flutter/foundation.dart';

class GraphQLClientService {
static GraphQLClient? _client;
static final AuthService _authService = AuthService();

static Future<GraphQLClient> getClient() async {
if (_client != null) return _client!;

final httpLink = HttpLink(
AppConfig.apiEndpoint,
defaultHeaders: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'OpenLift-Flutter/${AppConfig.appVersion}',
},
);

// Authentication link - adds JWT tokens to requests
final authLink = AuthLink(
getToken: () async {
final token = await _authService.getValidAccessToken();
return token != null ? 'Bearer $token' : null;
},
);

// HMAC signing link for production requests
final hmacLink = Link.function((request, [forward]) async {
if (AppConfig.isProduction) {
try {
final userId = await _authService._storage.read(key: 'user_id');
if (userId != null) {
final hmacHeaders = await _authService.generateHMACHeaders(
operation: request.operation.operationName ?? 'unknown',
variables: request.variables,
userId: userId,
);

request = request.updateContextEntry<HttpLinkHeaders>(
(headers) => HttpLinkHeaders(
headers: {
...headers?.headers ?? {},
...hmacHeaders,
},
),
);
}
} catch (e) {
if (kDebugMode) {
print('HMAC signing failed: $e');
}
}
}

return forward!(request);
});

// Error handling link - handles auth errors and token refresh
final errorLink = ErrorLink(
errorHandler: (ErrorLinkException exception) async {
if (exception.response?.errors?.any(
(error) => error.extensions?['code'] == 'UNAUTHENTICATED'
) == true) {
await _handleAuthError();
}

if (exception.response?.errors?.any(
(error) => error.extensions?['code'] == 'HMAC_VERIFICATION_FAILED'
) == true) {
await _handleHMACError();
}
},
);

// Logging link for development
Link? loggingLink;
if (kDebugMode) {
loggingLink = Link.function((request, [forward]) async {
final stopwatch = Stopwatch()..start();
print('🔍 GraphQL Request: ${request.operation.operationName}');

final response = await forward!(request);
stopwatch.stop();

if (response.errors?.isNotEmpty == true) {
print('❌ GraphQL Errors (${stopwatch.elapsedMilliseconds}ms):');
for (final error in response.errors!) {
print(' - ${error.message}');
}
} else {
print('✅ GraphQL Success: ${request.operation.operationName} (${stopwatch.elapsedMilliseconds}ms)');
}

return response;
});
}

// Chain all links together
final links = [
if (loggingLink != null) loggingLink,
errorLink,
authLink,
hmacLink,
httpLink,
];

_client = GraphQLClient(
link: Link.from(links),
cache: GraphQLCache(
store: HiveStore(),
// Cache configuration optimized for fitness tracking
typePolicies: {
'User': TypePolicy(
keyFields: {'id'},
fields: {
'workoutHistory': FieldPolicy(
keyArgs: ['first', 'after'],
merge: (existing, incoming, {args}) {
// Merge pagination results
return _mergePaginatedResults(existing, incoming, args);
},
),
},
),
'Exercise': TypePolicy(keyFields: {'id'}),
'WorkoutSession': TypePolicy(keyFields: {'id'}),
'Program': TypePolicy(keyFields: {'id'}),
},
),
defaultPolicies: DefaultPolicies(
query: Policies(
cachePolicy: CachePolicy.cacheFirst,
errorPolicy: ErrorPolicy.all,
),
mutate: Policies(
cachePolicy: CachePolicy.networkOnly,
errorPolicy: ErrorPolicy.all,
),
watchQuery: Policies(
cachePolicy: CachePolicy.cacheAndNetwork,
errorPolicy: ErrorPolicy.all,
),
),
);

return _client!;
}

/// Reset client (called after logout)
static void resetClient() {
_client?.cache.reset();
_client = null;
}

/// Handle authentication errors
static Future<void> _handleAuthError() async {
await _authService.logout();
// Navigate to login screen
NavigationService.pushReplacementNamed('/login');
}

/// Handle HMAC verification errors
static Future<void> _handleHMACError() async {
// HMAC errors indicate potential security issues
await _authService.logout();
NavigationService.pushReplacementNamed('/login', arguments: {
'error': 'Security verification failed. Please log in again.'
});
}

/// Merge paginated GraphQL results
static Map<String, dynamic>? _mergePaginatedResults(
Map<String, dynamic>? existing,
Map<String, dynamic>? incoming,
Map<String, dynamic>? args,
) {
if (existing == null) return incoming;
if (incoming == null) return existing;

final existingEdges = existing['edges'] as List? ?? [];
final incomingEdges = incoming['edges'] as List? ?? [];

return {
...incoming,
'edges': [...existingEdges, ...incomingEdges],
};
}
}

3. Authentication State Management

Provider-Based Authentication State

// lib/providers/auth_provider.dart
import 'package:flutter/material.dart';

class AuthProvider extends ChangeNotifier {
final AuthService _authService = AuthService();

User? _currentUser;
bool _isAuthenticated = false;
bool _isLoading = false;
String? _error;

User? get currentUser => _currentUser;
bool get isAuthenticated => _isAuthenticated;
bool get isLoading => _isLoading;
String? get error => _error;

/// Initialize authentication state on app startup
Future<void> initialize() async {
_setLoading(true);

try {
final isAuth = await _authService.isAuthenticated();
if (isAuth) {
_currentUser = await _authService.getCurrentUser();
_isAuthenticated = _currentUser != null;
} else {
_isAuthenticated = false;
_currentUser = null;
}
} catch (e) {
_setError('Failed to initialize authentication: $e');
_isAuthenticated = false;
_currentUser = null;
} finally {
_setLoading(false);
}
}

/// Login with email and password
Future<AuthResult> login(String email, String password) async {
_setLoading(true);
_clearError();

try {
final result = await _authService.login(email, password);

if (result.success && result.user != null) {
_currentUser = result.user;
_isAuthenticated = true;
notifyListeners();
}

return result;
} catch (e) {
final error = e is AuthException ? e.message : 'Login failed: $e';
_setError(error);
return AuthResult.failure(error);
} finally {
_setLoading(false);
}
}

/// Register new user account
Future<AuthResult> register({
required String email,
required String username,
required String password,
required String displayName,
FitnessLevel? fitnessLevel,
UnitPreferences? unitPreferences,
}) async {
_setLoading(true);
_clearError();

try {
final result = await _authService.register(
email: email,
username: username,
password: password,
displayName: displayName,
fitnessLevel: fitnessLevel,
unitPreferences: unitPreferences,
);

if (result.success && result.user != null) {
_currentUser = result.user;
_isAuthenticated = true;
notifyListeners();
}

return result;
} catch (e) {
final error = e is AuthException ? e.message : 'Registration failed: $e';
_setError(error);
return AuthResult.failure(error);
} finally {
_setLoading(false);
}
}

/// Logout current user
Future<void> logout() async {
_setLoading(true);

try {
await _authService.logout();
_currentUser = null;
_isAuthenticated = false;
_clearError();

// Reset GraphQL client
GraphQLClientService.resetClient();

notifyListeners();
} catch (e) {
_setError('Logout failed: $e');
} finally {
_setLoading(false);
}
}

/// Update current user profile
void updateUser(User updatedUser) {
_currentUser = updatedUser;
notifyListeners();
}

/// Check authentication status
Future<void> checkAuthStatus() async {
final isAuth = await _authService.isAuthenticated();
if (isAuth != _isAuthenticated) {
if (isAuth) {
_currentUser = await _authService.getCurrentUser();
} else {
_currentUser = null;
}
_isAuthenticated = isAuth;
notifyListeners();
}
}

void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}

void _setError(String error) {
_error = error;
notifyListeners();
}

void _clearError() {
_error = null;
notifyListeners();
}
}

4. Authentication UI Components

Login Screen

// lib/screens/auth/login_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class LoginScreen extends StatefulWidget {
const LoginScreen({Key? key}) : super(key: key);

@override
State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();

bool _obscurePassword = true;
bool _rememberMe = false;

@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Login'),
backgroundColor: Colors.transparent,
elevation: 0,
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Consumer<AuthProvider>(
builder: (context, authProvider, child) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// App Logo
const Center(
child: Icon(
Icons.fitness_center,
size: 80,
color: Colors.blue,
),
),
const SizedBox(height: 32),

// Welcome Text
Text(
'Welcome Back!',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Sign in to continue your fitness journey',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),

// Email Field
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
if (!value.contains('@') || !value.contains('.')) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),

// Password Field
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility : Icons.visibility_off,
),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
border: const OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
onFieldSubmitted: (_) => _handleLogin(),
),
const SizedBox(height: 16),

// Remember Me & Forgot Password
Row(
children: [
Checkbox(
value: _rememberMe,
onChanged: (value) => setState(() => _rememberMe = value ?? false),
),
const Text('Remember me'),
const Spacer(),
TextButton(
onPressed: () => Navigator.pushNamed(context, '/forgot-password'),
child: const Text('Forgot Password?'),
),
],
),
const SizedBox(height: 24),

// Login Button
ElevatedButton(
onPressed: authProvider.isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: authProvider.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Login', style: TextStyle(fontSize: 16)),
),

// Error Display
if (authProvider.error != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
border: Border.all(color: Colors.red.shade200),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade600),
const SizedBox(width: 8),
Expanded(
child: Text(
authProvider.error!,
style: TextStyle(color: Colors.red.shade600),
),
),
],
),
),
],

const Spacer(),

// Sign Up Link
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Don't have an account? "),
TextButton(
onPressed: () => Navigator.pushReplacementNamed(context, '/register'),
child: const Text('Sign Up'),
),
],
),
],
),
);
},
),
),
),
);
}

Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;

final authProvider = Provider.of<AuthProvider>(context, listen: false);

final result = await authProvider.login(
_emailController.text.trim(),
_passwordController.text,
);

if (result.success && mounted) {
Navigator.pushReplacementNamed(context, '/home');
}
}
}

5. Security Best Practices

Secure Token Storage

// lib/services/secure_storage_service.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'dart:convert';

class SecureStorageService {
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(
// Use Android Keystore for additional security
encryptedSharedPreferences: true,
keyCipherAlgorithm: KeyCipherAlgorithm.RSA_ECB_PKCS1Padding,
storageCipherAlgorithm: StorageCipherAlgorithm.AES_GCM_NoPadding,
),
iOptions: IOSOptions(
// Use iOS Keychain with specific accessibility
accessibility: IOSAccessibility.first_unlock_this_device,
groupId: 'group.com.openlift.app', // App Group for shared keychain
accountName: 'OpenLift',
synchronizable: false, // Don't sync to iCloud for security
),
);

/// Store sensitive data with encryption
static Future<void> storeSecureData(String key, dynamic data) async {
final jsonString = json.encode(data);
await _storage.write(key: key, value: jsonString);
}

/// Retrieve and decrypt sensitive data
static Future<T?> getSecureData<T>(String key) async {
final jsonString = await _storage.read(key: key);
if (jsonString == null) return null;

try {
final data = json.decode(jsonString);
return data as T?;
} catch (e) {
// Data corrupted, remove it
await _storage.delete(key: key);
return null;
}
}

/// Clear all secure storage (logout)
static Future<void> clearAll() async {
await _storage.deleteAll();
}

/// Check if secure storage contains key
static Future<bool> containsKey(String key) async {
return await _storage.containsKey(key: key);
}
}

Network Security Configuration

// lib/config/network_security.dart
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio_certificate_pinning/dio_certificate_pinning.dart';

class NetworkSecurity {
/// Configure certificate pinning for production
static void configureCertificatePinning(Dio dio) {
if (AppConfig.isProduction) {
dio.interceptors.add(
CertificatePinningInterceptor(
allowedSHAFingerprints: [
// OpenLift API server certificate fingerprints
'YOUR_PRODUCTION_SHA256_FINGERPRINT_HERE',
'YOUR_BACKUP_SHA256_FINGERPRINT_HERE',
],
),
);
}
}

/// Configure secure HTTP client
static HttpClient createSecureHttpClient() {
final client = HttpClient();

// Production security settings
if (AppConfig.isProduction) {
client.badCertificateCallback = (cert, host, port) {
// Only allow specific certificates in production
return _validateCertificate(cert, host);
};
}

// Development settings allow self-signed certificates
if (AppConfig.isDevelopment) {
client.badCertificateCallback = (cert, host, port) => true;
}

return client;
}

static bool _validateCertificate(X509Certificate cert, String host) {
// Implement certificate validation logic
// This should check against pinned certificates
return false; // Reject by default
}
}

Biometric Authentication Support

// lib/services/biometric_auth_service.dart
import 'package:local_auth/local_auth.dart';
import 'package:local_auth_android/local_auth_android.dart';
import 'package:local_auth_ios/local_auth_ios.dart';

class BiometricAuthService {
static final LocalAuthentication _localAuth = LocalAuthentication();

/// Check if biometric authentication is available
static Future<bool> isBiometricAvailable() async {
try {
final isAvailable = await _localAuth.canCheckBiometrics;
final isDeviceSupported = await _localAuth.isDeviceSupported();
return isAvailable && isDeviceSupported;
} catch (e) {
return false;
}
}

/// Get available biometric types
static Future<List<BiometricType>> getAvailableBiometrics() async {
try {
return await _localAuth.getAvailableBiometrics();
} catch (e) {
return [];
}
}

/// Authenticate using biometrics
static Future<bool> authenticateWithBiometrics({
String localizedReason = 'Authenticate to access your workout data',
}) async {
try {
final isAuthenticated = await _localAuth.authenticate(
localizedReason: localizedReason,
authMessages: const [
AndroidAuthMessages(
signInTitle: 'Biometric Authentication Required',
cancelButton: 'Use Password Instead',
),
IOSAuthMessages(
cancelButton: 'Use Password Instead',
),
],
options: const AuthenticationOptions(
biometricOnly: false,
stickyAuth: true,
sensitiveTransaction: true,
),
);

return isAuthenticated;
} catch (e) {
return false;
}
}

/// Enable biometric authentication for user
static Future<bool> enableBiometricAuth() async {
if (!await isBiometricAvailable()) return false;

final isAuthenticated = await authenticateWithBiometrics(
localizedReason: 'Enable biometric authentication for faster login',
);

if (isAuthenticated) {
await SecureStorageService.storeSecureData('biometric_enabled', true);
return true;
}

return false;
}

/// Disable biometric authentication
static Future<void> disableBiometricAuth() async {
await SecureStorageService.storeSecureData('biometric_enabled', false);
}

/// Check if biometric authentication is enabled
static Future<bool> isBiometricEnabled() async {
final enabled = await SecureStorageService.getSecureData<bool>('biometric_enabled');
return enabled ?? false;
}
}

6. Production HMAC Implementation

HMAC Request Signing

// lib/services/hmac_service.dart
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';

class HMACService {
/// Generate HMAC signature for request
static String generateSignature({
required String secretKey,
required String httpMethod,
required String requestPath,
required Map<String, dynamic> requestBody,
required String timestamp,
required String userId,
}) {
// Create canonical request string
final canonicalRequest = _createCanonicalRequest(
httpMethod: httpMethod,
requestPath: requestPath,
requestBody: requestBody,
timestamp: timestamp,
userId: userId,
);

// Generate HMAC signature
final key = utf8.encode(secretKey);
final message = utf8.encode(canonicalRequest);
final hmac = Hmac(sha256, key);
final digest = hmac.convert(message);

return digest.toString();
}

/// Create canonical request string for HMAC signing
static String _createCanonicalRequest({
required String httpMethod,
required String requestPath,
required Map<String, dynamic> requestBody,
required String timestamp,
required String userId,
}) {
// Sort request body keys for consistent signature
final sortedBody = _sortMapKeys(requestBody);
final bodyJson = json.encode(sortedBody);

// Create canonical request components
final components = [
httpMethod.toUpperCase(),
requestPath,
bodyJson,
timestamp,
userId,
];

return components.join('\n');
}

/// Recursively sort map keys for consistent hashing
static Map<String, dynamic> _sortMapKeys(Map<String, dynamic> map) {
final sortedMap = <String, dynamic>{};
final sortedKeys = map.keys.toList()..sort();

for (final key in sortedKeys) {
final value = map[key];
if (value is Map<String, dynamic>) {
sortedMap[key] = _sortMapKeys(value);
} else if (value is List) {
sortedMap[key] = value.map((item) {
return item is Map<String, dynamic> ? _sortMapKeys(item) : item;
}).toList();
} else {
sortedMap[key] = value;
}
}

return sortedMap;
}

/// Verify HMAC signature (for testing purposes)
static bool verifySignature({
required String secretKey,
required String signature,
required String httpMethod,
required String requestPath,
required Map<String, dynamic> requestBody,
required String timestamp,
required String userId,
}) {
final expectedSignature = generateSignature(
secretKey: secretKey,
httpMethod: httpMethod,
requestPath: requestPath,
requestBody: requestBody,
timestamp: timestamp,
userId: userId,
);

return signature == expectedSignature;
}

/// Check if timestamp is within acceptable range (5 minutes)
static bool isTimestampValid(String timestamp) {
try {
final requestTime = int.parse(timestamp);
final currentTime = DateTime.now().millisecondsSinceEpoch;
final timeDifference = (currentTime - requestTime).abs();

// Allow 5 minutes of clock skew
return timeDifference <= 300000; // 5 minutes in milliseconds
} catch (e) {
return false;
}
}
}

7. Authentication Testing

Authentication Test Suite

// test/services/auth_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';

@GenerateNiceMocks([
MockSpec<GraphQLClient>(),
MockSpec<FlutterSecureStorage>(),
])
import 'auth_service_test.mocks.dart';

void main() {
group('AuthService', () {
late AuthService authService;
late MockGraphQLClient mockClient;
late MockFlutterSecureStorage mockStorage;

setUp(() {
mockClient = MockGraphQLClient();
mockStorage = MockFlutterSecureStorage();
authService = AuthService();
});

group('login', () {
test('should login successfully with valid credentials', () async {
// Arrange
const email = 'test@example.com';
const password = 'password123';
final mockResponse = QueryResult(
data: {
'loginUser': {
'accessToken': 'mock_access_token',
'refreshToken': 'mock_refresh_token',
'user': {
'id': 'user_123',
'email': email,
'username': 'testuser',
'displayName': 'Test User',
}
}
},
source: QueryResultSource.network,
);

when(mockClient.mutate(any)).thenAnswer((_) async => mockResponse);

// Act
final result = await authService.login(email, password);

// Assert
expect(result.success, true);
expect(result.user?.email, email);
expect(result.user?.username, 'testuser');

verify(mockStorage.write(
key: 'access_token',
value: 'mock_access_token',
)).called(1);
verify(mockStorage.write(
key: 'refresh_token',
value: 'mock_refresh_token',
)).called(1);
});

test('should throw AuthException for invalid credentials', () async {
// Arrange
final mockResponse = QueryResult(
exception: OperationException(
graphqlErrors: [
GraphQLError(
message: 'Invalid credentials',
extensions: {'code': 'INVALID_CREDENTIALS'},
),
],
),
source: QueryResultSource.network,
);

when(mockClient.mutate(any)).thenAnswer((_) async => mockResponse);

// Act & Assert
expect(
() => authService.login('invalid@example.com', 'wrongpassword'),
throwsA(isA<AuthException>().having(
(e) => e.message,
'message',
contains('Invalid email or password'),
)),
);
});
});

group('token refresh', () {
test('should refresh token successfully', () async {
// Arrange
when(mockStorage.read(key: 'refresh_token'))
.thenAnswer((_) async => 'valid_refresh_token');

final mockResponse = QueryResult(
data: {
'refreshToken': {
'accessToken': 'new_access_token',
'refreshToken': 'new_refresh_token',
'user': {'id': 'user_123'}
}
},
source: QueryResultSource.network,
);

when(mockClient.mutate(any)).thenAnswer((_) async => mockResponse);

// Act
final token = await authService.getValidAccessToken();

// Assert
expect(token, 'new_access_token');
verify(mockStorage.write(
key: 'access_token',
value: 'new_access_token',
)).called(1);
});

test('should logout user when refresh token is invalid', () async {
// Arrange
when(mockStorage.read(key: 'refresh_token'))
.thenAnswer((_) async => 'invalid_refresh_token');

final mockResponse = QueryResult(
exception: OperationException(
graphqlErrors: [
GraphQLError(
message: 'Invalid refresh token',
extensions: {'code': 'INVALID_REFRESH_TOKEN'},
),
],
),
source: QueryResultSource.network,
);

when(mockClient.mutate(any)).thenAnswer((_) async => mockResponse);

// Act & Assert
expect(
() => authService.getValidAccessToken(),
throwsA(isA<AuthException>().having(
(e) => e.message,
'message',
contains('Session expired'),
)),
);

// Verify logout was called
verify(mockStorage.delete(key: 'access_token')).called(1);
verify(mockStorage.delete(key: 'refresh_token')).called(1);
});
});

group('HMAC signing', () {
test('should generate HMAC headers in production', () async {
// Arrange
when(mockStorage.read(key: 'hmac_key'))
.thenAnswer((_) async => 'secret_hmac_key');

// Mock production environment
AppConfig.setEnvironment('production');

// Act
final headers = await authService.generateHMACHeaders(
operation: 'GetUser',
variables: {'userId': 'user_123'},
userId: 'user_123',
);

// Assert
expect(headers.containsKey('X-Signature'), true);
expect(headers.containsKey('X-Timestamp'), true);
expect(headers.containsKey('X-User-ID'), true);
expect(headers['X-User-ID'], 'user_123');
});

test('should not generate HMAC headers in development', () async {
// Arrange
AppConfig.setEnvironment('development');

// Act
final headers = await authService.generateHMACHeaders(
operation: 'GetUser',
variables: {'userId': 'user_123'},
userId: 'user_123',
);

// Assert
expect(headers.isEmpty, true);
});
});
});

group('JWT Token Validation', () {
test('should correctly identify expired tokens', () {
// Create expired JWT token for testing
final expiredToken = _createTestJWT(
payload: {
'exp': DateTime.now().subtract(Duration(hours: 1))
.millisecondsSinceEpoch ~/ 1000,
'userId': 'user_123',
},
);

final isExpired = authService._isTokenExpiredSoon(
expiredToken,
bufferSeconds: 0,
);

expect(isExpired, true);
});

test('should correctly identify valid tokens', () {
// Create valid JWT token for testing
final validToken = _createTestJWT(
payload: {
'exp': DateTime.now().add(Duration(hours: 1))
.millisecondsSinceEpoch ~/ 1000,
'userId': 'user_123',
},
);

final isExpired = authService._isTokenExpiredSoon(
validToken,
bufferSeconds: 0,
);

expect(isExpired, false);
});
});
}

// Helper function to create test JWT tokens
String _createTestJWT({required Map<String, dynamic> payload}) {
final header = base64Url.encode(utf8.encode(json.encode({
'typ': 'JWT',
'alg': 'HS256',
})));

final encodedPayload = base64Url.encode(utf8.encode(json.encode(payload)));

// Simple signature for testing (not secure)
final signature = base64Url.encode(utf8.encode('test_signature'));

return '$header.$encodedPayload.$signature';
}

8. Error Handling & Recovery

Comprehensive Error Handling

// lib/utils/auth_error_handler.dart
import 'package:flutter/material.dart';

class AuthErrorHandler {
/// Handle authentication errors globally
static void handleAuthError(
BuildContext context,
dynamic error, {
VoidCallback? onRetry,
VoidCallback? onLogout,
}) {
String title;
String message;
List<Widget> actions;

if (error is AuthException) {
switch (error.errorCode) {
case 'NETWORK_ERROR':
title = 'Connection Problem';
message = 'Please check your internet connection and try again.';
actions = [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
if (onRetry != null)
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
onRetry();
},
child: const Text('Retry'),
),
];
break;

case 'SESSION_EXPIRED':
title = 'Session Expired';
message = 'Your session has expired. Please log in again.';
actions = [
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
onLogout?.call() ?? _navigateToLogin(context);
},
child: const Text('Login'),
),
];
break;

case 'RATE_LIMITED':
title = 'Too Many Attempts';
message = 'You\'ve made too many requests. Please wait before trying again.';
actions = [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
];
break;

default:
title = 'Authentication Error';
message = error.message;
actions = [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
];
}
} else {
title = 'Unexpected Error';
message = 'An unexpected error occurred. Please try again.';
actions = [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
if (onRetry != null)
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
onRetry();
},
child: const Text('Retry'),
),
];
}

showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: actions,
),
);
}

/// Navigate to login screen
static void _navigateToLogin(BuildContext context) {
Navigator.of(context).pushNamedAndRemoveUntil(
'/login',
(route) => false,
);
}

/// Show snack bar for minor errors
static void showErrorSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red.shade600,
behavior: SnackBarBehavior.floating,
action: SnackBarAction(
label: 'Dismiss',
textColor: Colors.white,
onPressed: () => ScaffoldMessenger.of(context).hideCurrentSnackBar(),
),
),
);
}
}

Summary

This authentication guide provides:

  1. Complete JWT Authentication Flow: Login, registration, token refresh, and logout
  2. Production-Grade Security: HMAC request signing, secure token storage, certificate pinning
  3. GraphQL Integration: Seamless authentication with GraphQL client configuration
  4. State Management: Provider-based authentication state for Flutter applications
  5. UI Components: Production-ready login and registration screens
  6. Biometric Support: Fingerprint and face recognition authentication
  7. Comprehensive Testing: Unit tests for all authentication scenarios
  8. Error Handling: Robust error handling with user-friendly messaging

⚠️ Critical Security Reminders

  • Thin Client Only: All authentication logic and validation occurs server-side
  • Secure Storage: Always use platform-appropriate secure storage for tokens
  • HMAC Signing: Required for production API requests for integrity verification
  • Certificate Pinning: Implement certificate pinning to prevent man-in-the-middle attacks
  • Token Refresh: Automatic token refresh prevents authentication interruptions
  • Biometric Integration: Provide convenient biometric authentication while maintaining security

Next Steps

  1. Implement Authentication: Follow the Flutter code examples to implement authentication
  2. Configure Security: Set up HMAC keys and certificate pinning for production
  3. Add Biometrics: Implement biometric authentication for improved UX
  4. Test Thoroughly: Use the provided test suite to validate authentication flows
  5. Handle Errors: Implement comprehensive error handling for production robustness

Next Guide: Continue with Client Architecture Patterns for proper application structure.