DEV Community

Cover image for Authentication Flow with Flutter & AWS Amplify
Offline Programmer
Offline Programmer

Posted on

Authentication Flow with Flutter & AWS Amplify

This post is about implementing a full authentication flow in Flutter, using various AWS Amplify Auth methods. The aim is to build a robust authentication flow using appropriate state management techniques to separate UI, logic, and authentication code and present user-friendly error messages.

The widget tree is straightforward. We will present a sign in page for the user, and once the authentication is done, we will show a homepage with a sign-out button.

LAT-Page-2 (1)

We packed the code with cool concepts and ideas. We used providers, enums, custom buttons, and more. We tried to follow best practices to produce a modular, testable & maintainable code.

We used the Amplify Admin UI to configure the authentication mechanisms.

Screen Shot 2021-04-15 at 1.38.02 PM

We will use two providers in this flow:

AppUser: This is the primary provider where we will configure Amplify & use it to authenticate the user. We will use ChangeNotifier to track the authentication state.

 class AppUser extends ChangeNotifier { bool isSignedIn = false; String username; AppUser() { if (!Amplify.isConfigured) configureAmplify(); } void configureAmplify() async { AmplifyAuthCognito authPlugin = AmplifyAuthCognito(); Amplify.addPlugins([authPlugin]); try { await Amplify.configure(amplifyconfig); } catch (e) { print('Error ' + e.toString()); } finally { // For development let's make sure we are signed out signOut(); } } void signIn(AuthProvider authProvider) async { try { await Amplify.Auth.signInWithWebUI(provider: authProvider); isSignedIn = true; notifyListeners(); } catch (e) { throw e; } } void signOut() async { try { await Amplify.Auth.signOut(); isSignedIn = false; notifyListeners(); } on AuthException catch (e) { print(e.message); } } Future<bool> registerWithEmailAndPassword( String email, String password) async { try { Map<String, String> userAttributes = { 'email': email, 'preferred_username': email, // additional attributes as needed }; await Amplify.Auth.signUp( username: email, password: password, options: CognitoSignUpOptions(userAttributes: userAttributes)); return true; } on AuthException catch (e) { print(e.message); throw e; } } signInWithEmailAndPassword(String email, String password) async { try { SignInResult res = await Amplify.Auth.signIn( username: email.trim(), password: password.trim(), ); isSignedIn = res.isSignedIn; } catch (e) { throw e; } } confirmRegisterWithCode(String email, String code) async { try { SignUpResult res = await Amplify.Auth.confirmSignUp( username: email, confirmationCode: code); isSignedIn = res.isSignUpComplete; notifyListeners(); return true; } on AuthException catch (e) { throw e; } } } 
Enter fullscreen mode Exit fullscreen mode

EmailSignIn: is the provider for the email & password auth. We will use the ChangeNotifier to update the UI, e.g., setting the button texts, error messages...etc.

 class EmailSignIn with EmailAndPasswordValidator, ChangeNotifier { final AppUser appUser; String email; String password; EmailSignInFormType formType; bool isLoading; bool submitted; String code; EmailSignIn({ @required this.appUser, this.email = '', this.password = '', this.formType = EmailSignInFormType.signIn, this.isLoading = false, this.submitted = false, this.code = '', }); String get primaryButtonText { switch (formType) { case EmailSignInFormType.signIn: return 'Sign In'; case EmailSignInFormType.register: return 'Create an account'; case EmailSignInFormType.confirm: return 'Confirm Sign Up'; } } String get secondaryButtonText { return formType == EmailSignInFormType.signIn ? 'Need an account? Register' : 'Have an account? Sign in'; } String get passwordErrorText { bool showErrorText = submitted && !passwordValidator.isValid(password); return showErrorText ? invalidPasswordErrorText : null; } String get emailErrorText { bool showErrorText = submitted && !emailValidator.isValid(email); return showErrorText ? invalidEmailErrorText : null; } bool get submitEnabled { return emailValidator.isValid(email) && passwordValidator.isValid(password) && !isLoading; } void updateEmail(String email) => updateWith(email: email); void updateCode(String code) => updateWith(code: code); void updatePassword(String password) => updateWith(password: password); void toggleFormType() { updateWith( submitted: false, email: '', password: '', code: '', isLoading: false, formType: this.formType == EmailSignInFormType.signIn ? EmailSignInFormType.register : EmailSignInFormType.signIn); } Future<void> submit() async { updateWith(submitted: true, isLoading: true); try { switch (formType) { case EmailSignInFormType.signIn: final user = await appUser.signInWithEmailAndPassword(email, password); break; case EmailSignInFormType.register: final isSignedUp = await appUser.registerWithEmailAndPassword(email, password); if (isSignedUp) { updateWith( formType: EmailSignInFormType.confirm, isLoading: false, submitted: false); } break; case EmailSignInFormType.confirm: final user = await appUser.confirmRegisterWithCode(email, code); } } catch (e) { updateWith(isLoading: false); rethrow; } } void updateWith({ String email, String password, EmailSignInFormType formType, bool isLoading, bool submitted, String code, }) { this.email = email ?? this.email; this.password = password ?? this.password; this.formType = formType ?? this.formType; this.isLoading = isLoading ?? this.isLoading; this.submitted = submitted ?? this.submitted; this.code = code ?? this.code; notifyListeners(); } } 
Enter fullscreen mode Exit fullscreen mode

For the social sign-in button, we created a StatelessWidget to customize the button based on the Auth provider

 class SocialSignInButton extends StatelessWidget { final Color color; final String text; final Color textColor; final double height; static const double borderRadius = 4.0; final VoidCallback onPressed; final Buttons button; const SocialSignInButton({ Key key, @required this.color, @required this.onPressed, this.height: 50, @required this.button, @required this.text, @required this.textColor, }) : super(key: key); @override Widget build(BuildContext context) { return SizedBox( height: height, child: ElevatedButton( onPressed: onPressed, style: ElevatedButton.styleFrom( primary: color, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(borderRadius))), ), child: buildRow(), ), ); } Row buildRow() { switch (button) { case Buttons.Google: return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Image.asset('images/google-logo.png'), Text( text, style: TextStyle(color: textColor, fontSize: 15), ), Opacity(opacity: 0.0, child: Image.asset('images/google-logo.png')), ], ); case Buttons.Email: return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Icon( Icons.email, ), Text( text, style: TextStyle(color: textColor, fontSize: 15), ), Opacity( opacity: 0.0, child: Icon( Icons.email, ), ), ], ); case Buttons.Facebook: return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Image.asset('images/facebook-logo.png'), Text( text, style: TextStyle(color: textColor, fontSize: 15), ), Opacity( opacity: 0.0, child: Image.asset('images/facebook-logo.png')), ], ); case Buttons.Amazon: return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Image.asset('images/amazon-logo.png'), Text( text, style: TextStyle(color: textColor, fontSize: 15), ), Opacity(opacity: 0.0, child: Image.asset('images/amazon-logo.png')), ], ); case Buttons.Apple: return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Icon( FontAwesomeIcons.apple, color: Colors.white, ), Text( text, style: TextStyle(color: textColor, fontSize: 15), ), Opacity( opacity: 0.0, child: Icon( FontAwesomeIcons.apple, ), ), ], ); } } } 
Enter fullscreen mode Exit fullscreen mode

We used platform-aware dialogs to display errors to the users.

 Future<dynamic> showErrorDialog( BuildContext context, { @required String title, @required String content, String cancelActionText, @required String defaultActionText, }) { if (!Platform.isIOS) { return showDialog( barrierDismissible: false, context: context, builder: (context) => AlertDialog( title: Text(title), content: Text(content), actions: [ if (cancelActionText != null) TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text(cancelActionText), ), TextButton( onPressed: () => Navigator.of(context).pop(true), child: Text(defaultActionText), ) ], ), ); } return showCupertinoDialog( context: context, builder: (context) => CupertinoAlertDialog( title: Text(title), content: Text(content), actions: [ if (cancelActionText != null) CupertinoDialogAction( onPressed: () => Navigator.of(context).pop(false), child: Text(cancelActionText), ), CupertinoDialogAction( onPressed: () => Navigator.of(context).pop(true), child: Text(defaultActionText), ) ], ), ); } 
Enter fullscreen mode Exit fullscreen mode

We build a stateful widget to manage the Email & Password auth. The user can choose to create an account or sign in. we are using a basic validator to make sure the email & password are not empty.

 class EmailSignInForm extends StatefulWidget { final EmailSignIn model; const EmailSignInForm({Key key, this.model}) : super(key: key); static Widget create(BuildContext context) { final appUser = Provider.of<AppUser>(context, listen: false); return ChangeNotifierProvider<EmailSignIn>( create: (_) => EmailSignIn(appUser: appUser), child: Consumer<EmailSignIn>( builder: (_, model, __) => EmailSignInForm(model: model), ), ); } @override _EmailSignInFormState createState() => _EmailSignInFormState(); } class _EmailSignInFormState extends State<EmailSignInForm> { final TextEditingController _emailController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); final TextEditingController _codeController = TextEditingController(); final FocusNode _codeFocusNode = FocusNode(); final FocusNode _emailFocusNode = FocusNode(); final FocusNode _passwordFocusNode = FocusNode(); EmailSignIn get model => widget.model; void _emailEditingComplete() { if (model.emailValidator.isValid(model.email)) FocusScope.of(context).requestFocus(_passwordFocusNode); else FocusScope.of(context).requestFocus(_emailFocusNode); } @override void dispose() { _emailController.dispose(); _passwordController.dispose(); _codeController.dispose(); _codeFocusNode.dispose(); _emailFocusNode.dispose(); _passwordFocusNode.dispose(); super.dispose(); } Future<void> _submit() async { try { await model.submit(); if (model.submitted) { Navigator.of(context).pop(); } } catch (e) { showErrorDialog( context, title: 'Error', content: e.message, defaultActionText: 'Ok', ); } } void _toggleFormType() { model.toggleFormType(); _emailController.clear(); _passwordController.clear(); _codeController.clear(); } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: model.formType == EmailSignInFormType.confirm ? _buildConfirmchildren() : _buildFormchildren(), ), ); } List<Widget> _buildFormchildren() { return [ TextField( decoration: InputDecoration( enabled: model.isLoading == false, labelText: 'Email', hintText: 'test@test.com', errorText: model.emailErrorText, ), controller: _emailController, autocorrect: false, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, focusNode: _emailFocusNode, onEditingComplete: () => _emailEditingComplete(), onChanged: model.updateEmail, ), SizedBox( height: 8.0, ), TextField( decoration: InputDecoration( enabled: model.isLoading == false, labelText: 'Password', errorText: model.passwordErrorText, ), obscureText: true, controller: _passwordController, textInputAction: TextInputAction.done, focusNode: _passwordFocusNode, onEditingComplete: _submit, onChanged: model.updatePassword, ), SizedBox( height: 8.0, ), ElevatedButton( onPressed: model.submitEnabled ? _submit : null, child: Text( model.primaryButtonText, ), ), SizedBox( height: 8.0, ), TextButton( onPressed: !model.isLoading ? _toggleFormType : null, child: Text(model.secondaryButtonText), ) ]; } List<Widget> _buildConfirmchildren() { return [ TextField( decoration: InputDecoration( enabled: model.isLoading == false, labelText: 'Confirmation Code', hintText: 'The code we sent you', errorText: model.emailErrorText, ), controller: _codeController, autocorrect: false, keyboardType: TextInputType.text, textInputAction: TextInputAction.done, focusNode: _passwordFocusNode, onEditingComplete: _submit, onChanged: model.updateCode, ), SizedBox( height: 8.0, ), ElevatedButton( onPressed: model.submitEnabled ? _submit : null, child: Text( model.primaryButtonText, ), ), SizedBox( height: 8.0, ), ]; } } 
Enter fullscreen mode Exit fullscreen mode

The LandingPage will watch for the AppUser status to determine displaying the HomePage or the SignInPage

 class LandingPage extends StatelessWidget { @override Widget build(BuildContext context) { final appUser = context.watch<AppUser>().isSignedIn; print(appUser); return appUser ? HomePage() : SignInPage(); } } 
Enter fullscreen mode Exit fullscreen mode

The SignInPage will present all social auth options besides the Email & Password option.

 class SignInPage extends StatelessWidget { void _signInWithEmail(BuildContext context) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => EmailSignInPage(), fullscreenDialog: true), ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Amplify Auth Demo'), elevation: 10, ), body: _buildContent(context), backgroundColor: Colors.grey[200], ); } Widget _buildContent(BuildContext context) { return Padding( padding: EdgeInsets.all(16), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( child: Text( 'Sign In', textAlign: TextAlign.center, style: TextStyle( fontSize: 32, fontWeight: FontWeight.w600, ), ), height: 50.0, ), SizedBox( height: 48.0, ), SocialSignInButton( button: Buttons.Google, onPressed: () => context.read<AppUser>().signIn(AuthProvider.google), color: Colors.white, text: 'Sign in with Google', textColor: Colors.black87, ), SizedBox( height: 8.0, ), SocialSignInButton( button: Buttons.Facebook, onPressed: () => context.read<AppUser>().signIn(AuthProvider.facebook), color: Color(0xFF334D92), text: 'Sign in with Facebook', textColor: Colors.white, ), SizedBox( height: 8.0, ), SocialSignInButton( button: Buttons.Amazon, onPressed: () => context.read<AppUser>().signIn(AuthProvider.amazon), color: Colors.black54, text: 'Sign in with Amazon', textColor: Colors.white, ), SizedBox( height: 8.0, ), Text( 'Or', style: TextStyle( fontSize: 14, color: Colors.black87, ), textAlign: TextAlign.center, ), SizedBox( height: 8.0, ), SocialSignInButton( button: Buttons.Email, onPressed: () => _signInWithEmail(context), color: Colors.deepOrange, text: 'Sign in with email', textColor: Colors.white, ), ], ), ); } } 
Enter fullscreen mode Exit fullscreen mode

Finally, the HomePage will allow the user to sign out and go back to the SignInPage

 class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Amplify Auth Demo'), actions: [ TextButton( onPressed: () => context.read<AppUser>().signOut(), child: Text( 'Logout', style: TextStyle(color: Colors.white, fontSize: 14), ), ), ], ), ); } } 
Enter fullscreen mode Exit fullscreen mode

Check the code here

Reference Authentication Flow with Flutter & AWS Amplify

YouTube video demo here:

Authentication Flow with Flutter & AWS Amplify

This project shows how to implement a full authentication flow in Flutter, using various AWS Amplify sign-in methods.

Project goals

This project shows how to:

  • use the various AWS Amplify sign-in methods
  • build a robust authentication flow
  • use appropriate state management techniques to separate UI, logic and authentication code
  • handle errors and present user-friendly error messages
  • write production-ready code following best practices

Blog post: https://dev.to/offlineprogrammer/authentication-flow-with-flutter-aws-amplify-41fa






Follow me on Twitter for more tips about #coding, #learning, #technology...etc.

Check my Apps on Google Play

Cover image Franck on Unsplash

Top comments (3)

Collapse
 
garrettlove8 profile image
Garrett Love

Thanks for posting. I appreciate the relative easy with which authentication can be built out. But how would you actually go and test an app which has Amplify as a root dependency?

Collapse
 
offlineprogrammer profile image
Offline Programmer

Thanks for your comment. Are you asking about Mocking? if yes then check here docs.amplify.aws/cli/usage/mock/ let me know of any questions

Collapse
 
yawarosman profile image
Yawar Osman

it is such an awesome content, but could you please make a video of setting up google auth client id and configuring amplify auth, please