Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active July 11, 2024 23:11
Show Gist options
  • Save PlugFox/538d7cfdd7149770925fe4f434fc2e84 to your computer and use it in GitHub Desktop.
Save PlugFox/538d7cfdd7149770925fe4f434fc2e84 to your computer and use it in GitHub Desktop.
Authentication management example
/*
* Authentication management example
* HttpClient <--> AuthenticationController <--> Navigator or LoginScreen
*
* https://gist.github.com/PlugFox/538d7cfdd7149770925fe4f434fc2e84
* https://dartpad.dev?id=538d7cfdd7149770925fe4f434fc2e84
* Mike Matiunin <plugfox@gmail.com>, 12 July 2024
*/
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:meta/meta.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// User JWT
typedef Token = String;
/// Represents the current user state.
@immutable
sealed class User {
const User();
const factory User.authenticated(Token token) = AuthenticatedUser;
@literal
const factory User.unauthenticated() = UnauthenticatedUser;
T map<T>({
required T Function(AuthenticatedUser user) authenticated,
required T Function() unauthenticated,
});
}
/// Represents an authenticated user.
final class AuthenticatedUser extends User {
const AuthenticatedUser(this.token);
/// The user's token.
final Token token;
@override
T map<T>({
required T Function(AuthenticatedUser user) authenticated,
required T Function() unauthenticated,
}) =>
authenticated(this);
@override
int get hashCode => token.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AuthenticatedUser && token == other.token;
}
/// Represents an unauthenticated user.
final class UnauthenticatedUser extends User {
@literal
const UnauthenticatedUser();
@override
T map<T>({
required T Function(AuthenticatedUser user) authenticated,
required T Function() unauthenticated,
}) =>
unauthenticated();
@override
int get hashCode => -1;
@override
bool operator ==(Object other) =>
identical(this, other) || other is UnauthenticatedUser;
}
/// An interceptor that can be used to intercept requests and responses.
abstract interface class Interceptor {
/// Called before the request is sent
/// with the Request object.
Future<void> onRequest(String url);
/// Called before the response is returned
/// with the Request and Response objecs.
Future<void> onResponse(String url, int statusCode);
}
/// A simulated HTTP client that can be used to make requests.
class HttpClient {
HttpClient() : interceptors = <Interceptor>[];
/// The interceptors that will be called before/after each request.
final List<Interceptor> interceptors;
int _counter = 1;
/// Make a request to the specified URL.
Future<int> request(String url) async {
for (final interceptor in interceptors) {
await interceptor.onRequest(url);
}
// Emulate a network request and status code response.
// If that is not a "/login" request
// - we can get here a 401 error with low chance.
final int statusCode;
if (url.endsWith('/login')) {
_counter = 1;
statusCode = 200;
} else {
_counter++;
statusCode = _counter % 5 == 0 ? 401 : 200;
}
for (final interceptor in interceptors) {
await interceptor.onResponse(url, statusCode);
}
return statusCode;
}
}
/// An interceptor that adds authentication headers to the request.
/// It also refreshes the token if it's expired.
/// If the token is expired or invalid, it will call the `expired` callback.
class AuthenticationInterceptor implements Interceptor {
AuthenticationInterceptor({
required this.getToken,
required this.refreshToken,
required this.expired,
});
/// Get the current token.
final String Function() getToken;
/// Set or refresh the token.
final void Function(String token) refreshToken;
/// Called when the token is expired or invalid.
final void Function() expired;
@override
Future<void> onRequest(String url) async {
if (url.endsWith('/refresh') || url.endsWith('/login')) {
// Skip the interceptor for the refresh and login endpoints.
return;
}
final token = getToken();
if (token.isEmpty) expired();
// Add the token to the request headers.
}
@override
Future<void> onResponse(String url, int statusCode) async {
switch (statusCode) {
case 401 || 403:
expired();
case 200 when url.endsWith('/refresh') || url.endsWith('/login'):
refreshToken('JWT'
'#${(DateTime.now().copyWith(year: 1970).millisecondsSinceEpoch ~/ 1000).toRadixString(36)}');
default:
return;
}
}
}
/// Controller that manages the authentication state.
/// Of course real controller should be much more complex
/// and contain states like idle, loading, error, etc.
/// You can create it based on `ChangeNotifier` or `BLoC`.
extension type AuthenticationController._(ValueNotifier<User> _notifier)
implements ValueListenable<User> {
factory AuthenticationController(User user) =>
AuthenticationController._(ValueNotifier<User>((user)));
/// The current user state.
User get user => _notifier.value;
/// Sets the current user state.
void setUser(User user) => _notifier.value = user;
/// Logout the current user.
void logout() => setUser(const UnauthenticatedUser());
}
/// Dependencies required by the application.
/// You can add more dependencies and make another implementation if needed.
/// E.g. `class DependenciesFake implements Dependencies {...}`.
class Dependencies {
Dependencies();
/// Get the dependencies from the current context.
factory Dependencies.of(BuildContext context) =>
_InheritedDependencies.of(context);
/// The authentication controller.
late final AuthenticationController authenticationController;
/// The HTTP client.
late final HttpClient httpClient;
}
/// Initializes the dependencies required by the application.
Future<Dependencies> $initializeDependencies() async {
// Initialize the shared preferences with the mock values.
// ignore: invalid_use_of_visible_for_testing_member
SharedPreferences.setMockInitialValues(<String, Object>{});
final sharedPrefs = await SharedPreferences.getInstance();
// Create the authentication controller with the initial user state.
final authenticationController = AuthenticationController(() {
try {
switch (sharedPrefs.getString('token')) {
case String token when token.isNotEmpty:
return User.authenticated(token);
default:
return const User.unauthenticated();
}
} on Object {
return const User.unauthenticated();
}
}());
// Save the token to the shared preferences when the user changes.
// In real application you should implement that part
// in the `AuthenticationController` itself with:
// ```dart
// AuthenticationRepositoryImpl(
// local: AuthenticationLocalProviderSharedPrefsImpl(sharedPrefs),
// network: AuthenticationNetworkProviderHttpImpl(httpClient),
// )
// ```
authenticationController.addListener(() {
switch (authenticationController.user) {
case AuthenticatedUser user:
sharedPrefs.setString('token', user.token);
case UnauthenticatedUser _:
sharedPrefs.remove('token');
}
});
// Create the HTTP client with the authentication interceptor.
final httpClient = HttpClient()
..interceptors.addAll([
// Add the authentication interceptor to the HTTP client.
// It will add the token to the request headers and refresh it if needed.
// If the token is expired or invalid, it will call the `expired` callback
// and logout the user.
AuthenticationInterceptor(
getToken: () => switch (authenticationController.value) {
AuthenticatedUser user => user.token,
UnauthenticatedUser _ => throw Exception('User is not authenticated'),
},
refreshToken: (token) =>
authenticationController.setUser(User.authenticated(token)),
expired: () =>
authenticationController.setUser(const User.unauthenticated()),
),
]);
return Dependencies()
..authenticationController = authenticationController
..httpClient = httpClient;
}
/// The main function that initializes the application.
void main() => runZonedGuarded<void>(
() async {
final binding = WidgetsFlutterBinding.ensureInitialized()
..deferFirstFrame();
final dependencies = await $initializeDependencies();
runApp(
_InheritedDependencies(
dependencies: dependencies,
child: const App(),
),
);
binding.allowFirstFrame();
},
(error, stackTrace) => print('Error: $error'), // ignore: avoid_print
);
class _InheritedDependencies extends InheritedWidget {
const _InheritedDependencies({
required this.dependencies,
required super.child,
super.key, // ignore: unused_element
});
final Dependencies dependencies;
static Never _notFoundInheritedWidgetOfExactType() => throw ArgumentError(
'Out of scope, not found inherited widget '
'a _InheritedDependencies of the exact type',
'out_of_scope',
);
/// The state from the closest instance of this class
/// that encloses the given context.
/// e.g. `Dependencies.of(context)`
static Dependencies of(BuildContext context) =>
context
.getInheritedWidgetOfExactType<_InheritedDependencies>()
?.dependencies ??
_notFoundInheritedWidgetOfExactType();
@override
bool updateShouldNotify(covariant _InheritedDependencies oldWidget) => false;
}
/// The main application widget.
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Basic authentication management example',
home: const HomeScreen(),
debugShowCheckedModeBanner: false,
builder: (context, navigator) => ValueListenableBuilder<User>(
valueListenable: Dependencies.of(context).authenticationController,
builder: (context, user, child) => user.map<Widget>(
// Show the navigator and main application flow
// if the user is authenticated.
// Also provide the authenticated user to the underlying widgets.
authenticated: (user) => Provider<AuthenticatedUser>.value(
value: user,
child: navigator,
),
// Show the login screen if the user is not authenticated.
unauthenticated: () => const LoginScreen(),
),
child: navigator,
),
);
}
/// The login screen widget.
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
/// Login the user.
static void login(BuildContext context) => Dependencies.of(context)
.httpClient
.request('https://example.com/api/login');
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Login Screen'),
),
body: SafeArea(
child: Center(
child: ElevatedButton.icon(
onPressed: () => login(context),
label: const Text('Login'),
icon: const Icon(Icons.login),
),
),
),
);
}
/// The home screen widget.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
/// Make a request to the API.
static void request(BuildContext context) async {
final client = Dependencies.of(context).httpClient;
final statusCode = await client.request('https://example.com/api/method');
if (!context.mounted) return;
final messenger = ScaffoldMessenger.maybeOf(context)?..clearSnackBars();
if (messenger == null) return;
switch (statusCode) {
case 200:
messenger.showSnackBar(
const SnackBar(
content: Text('Request successful'),
),
);
case 401 || 403:
messenger.showSnackBar(
const SnackBar(
content: Text('Unauthorized request'),
backgroundColor: Colors.red,
),
);
case int code when code >= 400 && code < 600:
messenger.showSnackBar(
const SnackBar(
content: Text('Request failed'),
backgroundColor: Colors.red,
),
);
}
}
/// Logout the current user.
static void logout(BuildContext context) =>
Dependencies.of(context).authenticationController.logout();
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Home Screen'),
actions: <Widget>[
IconButton(
onPressed: () => logout(context),
icon: const Icon(Icons.logout),
),
],
),
body: SafeArea(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text(
'Current user: '
'${context.watch<AuthenticatedUser>().token}',
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => request(context),
label: const Text('Make request'),
icon: const Icon(Icons.send),
),
],
),
),
),
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment