flutter_secure_auth 1.0.0
flutter_secure_auth: ^1.0.0 copied to clipboard
A secure, lightweight authentication package for Flutter (REST + OAuth2 PKCE + token refresh) with secure local storage.
import 'package:flutter/material.dart';
import 'package:flutter_secure_auth/flutter_secure_auth.dart';
// ---------------------------------------------------------------------------
// flutter_secure_auth v1.1.0 — Complete Example App
//
// Demonstrates:
// • AuthService.create() quick factory
// • authStateChanges stream + StreamBuilder<AuthState>
// • isAuthenticated getter
// • AuthInterceptor drop-in HTTP client
// • signInWithPassword with typed AuthErrorCode handling
// • exchangeAuthorizationCode (PKCE)
// • createPkcePair()
// • AuthTokens.copyWith()
// • TokenStorage readGuard (biometric hook)
// • signOut with dispose()
// ---------------------------------------------------------------------------
void main() {
runApp(const FlutterSecureAuthExampleApp());
}
// ---------------------------------------------------------------------------
// 1. Create the AuthService once (at app level or via a provider)
// ---------------------------------------------------------------------------
final authService = AuthService.create(
'https://api.example.com',
// Override paths if your API uses non-standard endpoints:
// tokenPath: '/auth/token',
// revokePath: '/auth/logout',
);
// ---------------------------------------------------------------------------
// App root
// ---------------------------------------------------------------------------
class FlutterSecureAuthExampleApp extends StatelessWidget {
const FlutterSecureAuthExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'flutter_secure_auth Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.indigo,
useMaterial3: true,
),
// ---------------------------------------------------------------------------
// 2. Reactive auth routing with authStateChanges stream
// ---------------------------------------------------------------------------
home: StreamBuilder<AuthState>(
stream: authService.authStateChanges,
builder: (context, snapshot) {
final state = snapshot.data ?? const AuthState.unauthenticated();
if (state.isAuthenticated) {
return HomeScreen(tokens: state.tokens!);
}
return const LoginScreen();
},
),
);
}
}
// ---------------------------------------------------------------------------
// Login Screen
// ---------------------------------------------------------------------------
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailController = TextEditingController(text: 'demo@example.com');
final _passwordController = TextEditingController(text: 'password123');
bool _loading = false;
String? _errorMessage;
// -------------------------------------------------------------------------
// 3. Sign in with password + typed error handling via AuthErrorCode
// -------------------------------------------------------------------------
Future<void> _signIn() async {
setState(() {
_loading = true;
_errorMessage = null;
});
try {
await authService.signInWithPassword(
endpoint: Uri.parse('https://api.example.com/auth/login'),
username: _emailController.text.trim(),
password: _passwordController.text,
);
// authStateChanges stream will automatically navigate to HomeScreen
} on AuthException catch (e) {
String message;
switch (e.errorCode) {
case AuthErrorCode.unauthorized:
message = 'Invalid email or password. Please try again.';
case AuthErrorCode.serverError:
message = 'Server error. Please try again later.';
case AuthErrorCode.networkError:
message = 'No internet connection.';
default:
message = e.message;
}
setState(() => _errorMessage = message);
} finally {
setState(() => _loading = false);
}
}
// -------------------------------------------------------------------------
// 4. OAuth2 PKCE flow — generates code pair and exchanges it for tokens
// -------------------------------------------------------------------------
void _startPkceFlow() {
final pkce = createPkcePair(length: 64);
// In a real app you would launch this URL in a WebView / app browser:
final authUrl = Uri.parse('https://auth.example.com/authorize').replace(
queryParameters: {
'response_type': 'code',
'client_id': 'your-client-id',
'redirect_uri': 'com.example.app:/callback',
'scope': 'openid profile offline_access',
'code_challenge': pkce.codeChallenge,
'code_challenge_method': 'S256',
'state': pkce.state,
},
);
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('PKCE Flow'),
content: SelectableText(
'Authorization URL (open in browser):\n\n$authUrl\n\n'
'Code Verifier (stored securely):\n${pkce.codeVerifier}',
style: const TextStyle(fontSize: 12),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
// After capturing the returned `code`, call:
// await authService.exchangeAuthorizationCode(
// code: 'returned_code',
// codeVerifier: pkce.codeVerifier,
// redirectUri: Uri.parse('com.example.app:/callback'),
// );
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('flutter_secure_auth Demo')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.lock_outline, size: 64, color: Colors.indigo),
const SizedBox(height: 24),
const Text(
'flutter_secure_auth v1.1.0',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Secure OAuth2 + Token Lifecycle Management',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 32),
TextField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email_outlined),
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock_outline),
),
obscureText: true,
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade700),
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _loading ? null : _signIn,
icon: _loading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.login),
label: Text(_loading ? 'Signing in…' : 'Sign In with Password'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _startPkceFlow,
icon: const Icon(Icons.open_in_browser),
label: const Text('Sign In with OAuth2 PKCE'),
),
],
),
),
);
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
}
// ---------------------------------------------------------------------------
// Home Screen — shown when authenticated
// ---------------------------------------------------------------------------
class HomeScreen extends StatefulWidget {
final AuthTokens tokens;
const HomeScreen({super.key, required this.tokens});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
// -------------------------------------------------------------------------
// 5. AuthInterceptor — drop-in HTTP client with automatic Authorization header
// -------------------------------------------------------------------------
late final AuthInterceptor _client = AuthInterceptor(authService: authService);
String _apiResult = 'Tap "Fetch Profile" to make an authorized API request.';
bool _loading = false;
Future<void> _fetchProfile() async {
setState(() {
_loading = true;
_apiResult = 'Fetching…';
});
try {
// Authorization header is injected automatically.
// Token is refreshed if expired. 401 triggers one refresh + retry.
final response = await _client.get(
Uri.parse('https://api.example.com/profile'),
);
setState(() => _apiResult = 'Status: ${response.statusCode}\n${response.body}');
} on AuthException catch (e) {
setState(() => _apiResult = 'Auth error: ${e.errorCode} — ${e.message}');
} catch (e) {
setState(() => _apiResult = 'Error: $e');
} finally {
setState(() => _loading = false);
}
}
// -------------------------------------------------------------------------
// 6. copyWith() — update a single field on existing tokens
// -------------------------------------------------------------------------
void _showCopyWith() {
final updated = widget.tokens.copyWith(
accessToken: 'simulatedNewToken_${DateTime.now().millisecondsSinceEpoch}',
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('copyWith → ${updated.accessToken.substring(0, 32)}…')),
);
}
Future<void> _signOut() async {
await authService.signOut(revokeServerSide: true);
// authStateChanges stream will automatically navigate to LoginScreen
}
@override
Widget build(BuildContext context) {
final expiry = widget.tokens.timeToExpiry;
final expiryLabel = expiry.isNegative
? 'Expired'
: 'Expires in ${expiry.inMinutes}m ${expiry.inSeconds.remainder(60)}s';
return Scaffold(
appBar: AppBar(
title: const Text('Home — Authenticated'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Sign Out',
onPressed: _signOut,
),
],
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Token info card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'🔑 Current Token',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 8),
Text(
'Access: ${widget.tokens.accessToken.substring(0, 12)}…',
style: const TextStyle(fontFamily: 'monospace'),
),
Text(
'Has Refresh Token: ${widget.tokens.hasRefreshToken}',
),
Text(expiryLabel),
],
),
),
),
const SizedBox(height: 16),
// AuthInterceptor demo
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'🌐 AuthInterceptor Demo',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 8),
Text(
_apiResult,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _loading ? null : _fetchProfile,
icon: _loading
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.cloud_download_outlined),
label: const Text('Fetch Profile'),
),
],
),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _showCopyWith,
icon: const Icon(Icons.copy_outlined),
label: const Text('Demo: tokens.copyWith()'),
),
],
),
),
);
}
@override
void dispose() {
_client.close();
super.dispose();
}
}