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

License: MIT

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

Check my Apps on Google Play

Cover image Franck on Unsplash

Latest comments (3)

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

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