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

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