Serverless Authentication with AWS Amplify and Vuex

Serverless Authentication with AWS Amplify and Vuex

  • 2019-05-27 03:29 AM
  • 380

Services like Auth0 and Firebase have been the go-to for serverless authentication, but now you have an option that lets you stay within the AWS Stack: AWS Amplify.

Now it’s a lot easier to get some of the great AWS services on the client (securing storage with ease, Secure Lambda API calls with API Gateway, etc). Amplify has two main benefits:

  1. The Amplify-CLI will generate all the security and permissions automatically avoiding a lot of manual setup
  2. It comes with libraries to interact with the AWS stack on the client side in multiple languages and UI frameworks

You can use the Amplify Vue components on top of the core library which should handle most use cases. If you do decide to build your own components, it is probably for one of the following reasons:

  • Utilize an already loaded UI framework
  • Full customization of the experience
  • The components don’t support your requirements
  • You like to control all-the-things

Customize your own authentication components

This article will:

  1. Walk through the authentication/Cognito setup
  2. Show a Vuex module for interacting with Cognito
  3. Provide an example component to handle sign-in

How this example works

  1. Users log in with an email for a username
  2. Users must signup and confirm email address before logging in

Setting up AWS Amplify

For full customization, we will need to add our own user pool, but first use the client to set up all the permissions and services.

  1. Setup the AWS Amplify Cli (@aws-amplify/cli)
  2. Initiate your Amplify project amplify init
  3. Make sure you are in the desired AWS region
  4. Add a (temporary) Authentication/Cognito serviceamplify add auth
  5. Add any other services you want to use ie: Storage
  6. Push your project amplify push

Now we can setup AWS Cognito

  1. Login to AWS Cognito, selection the region, and Create a User Pool with the desired settings
  2. You probably will want to customize the User Pool email verification messages since this solution requires email confirmation
  3. In User Pool settings go to the App clients link and click Add another app client
  4. Create an app client but uncheck Generate client secret since this only applies to server-to-server communication:

Setting up Amplify on the Client

Amplify will generate an [aws-exports.js](https://github.com/nowpapercom/gotome-admin/blob/master/src/aws-exports.js "aws-exports.js") file but we can’t rely on it not getting overwritten, also you should add it to your .gitignore for security. In your main.js file add the amplify dependencies:

import Amplify from 'aws-amplify'

const awsExports = {
  // found in your generated aws-export.js file:
  aws_project_region: 'us-west-2',
  aws_cognito_identity_pool_id: 'us-west-2:00000000-0000-0000-0000-000000000000',

  // your new user pool region
  aws_cognito_region: 'us-west-2',
  
  // 'Pool Id' found under user pool 'General settings'
  aws_user_pools_id: 'us-west-2_000000000',
  
  // New 'App client id' under user pool 'App clients'
  aws_user_pools_web_client_id: '00000000000000000000000000',
  
  // Any other Amplify services settings
}

Amplify.configure(awsExports)

vue-customizing-amplify-authentication-settings.js

For the purpose of this demo I am placing secrets in code, never add this to your repo, use environment variables and secret storage.

Account Vuex Module Example

This Vuex module will do all the heavy lifting so your components deal with an easy API and you can easily check on the users’ authenticated state.

ogressiveMedia-canvas">

// Dependencies ===============

  import {Auth} from 'aws-amplify'
  const store = {namespaced: true}

// State ======================

  store.state = {
    authorized: false,
    user: null,
    loginError: '',
    signupError: '',
    confirm: false,
    confirmError: '',
  }

// Mutations ==================

  store.mutations = {
    user(state, user){
      state.authorized = !!user && user.attributes && user.attributes.email_verified
      state.user = user
    },
    confirm(state, showConfirm){
      state.confirm = !!showConfirm
    },
  }

// Actions ====================

  store.actions = {
    async login({dispatch, state}, {email, password}){
      state.loginError = ''
      try{
        await Auth.signIn(email, password)
      }catch(err){
        console.log(`Login Error [${err}]`)
        if(err)
          state.loginError = err.message || err
        return
      }
      await dispatch('fetchUser')
    },
    async signup({commit, state}, {email, password}){
      state.signupError = ''
      try{
        await Auth.signUp({
          username: email,
          email: email,
          password: password,
          attributes: {
            email: email,
          },
          validationData: [],
        })
        //switch email confirmation form
        commit('confirm', true)
      }catch(err){
        console.log(`Signup Error [${err}]`)
        if(err)
          state.signupError = err.message || err
        commit('confirm', false)
      }
    },
    async confirm({commit, dispatch, state}, {email, code}){
      state.confirmError = ''
      try{
        await Auth.confirmSignUp(email, code, {
          forceAliasCreation: true,
        })
      }catch(err){
        console.log(`Confirm Error [${err}]`)
        if(err)
          state.confirmError = err.message || err
        return
      }
      commit('confirm', false)
    },
    async authState({commit, dispatch}, state){
      if(state === 'signedIn')
        await dispatch('fetchUser')
      else
        commit('user', null)
    },
    async fetchUser({commit, dispatch}){
      try{
        const user = await Auth.currentAuthenticatedUser()
        const expires = user.getSignInUserSession().getIdToken().payload.exp - Math.floor(new Date().getTime() / 1000)
        console.log(`Token expires in ${expires} seconds`)
        setTimeout(async () => {
          console.log('Renewing Token')
          await dispatch('fetchUser')
        }, expires * 1000)
        commit('user', user)
      }catch(err){
        commit('user', null)
      }
    },
    async logout({commit}){
      await Auth.signOut()
      commit('user', null)
    },
  }

// Getters ====================

  store.getters = {
  }

// Export =====================

  export default store

vue-customizing-amplify-authentication-vuex-module.js

When a user logs in, a timer is set to automatically refresh the token when it expires.

Vuex Module Explained

State Variables**:**

  • authorized A boolean you can check if a user is authorized ($store.state.account.authorized)
  • user An object of the user’s information
  • loginError A string containing the Cognito response on an invalid login attempt
  • signupError A string containing the Cognito response on an invalid signup attempt
  • confirm Boolean indicating if the user wants to confirm their email address. This can happen right after signup or if a user comes back later.
  • confirmError A string containing the Cognito response on an invalid email confirmation

Module Usage:

These 3 module actions trigger the AWS Amplify Authentication/Cognito Service.

// login a user
this.$store.dispatch('account/login', {email, password})

/*
  signup a user -- after verifying your field rules:
  * password minimum characters
  * password strength policy
  * email or username requirements
*/
this.$store.dispatch('account/signup', {email, password})

// Validate confirmation code sent to the user's email address
this.$store.dispatch('account/confirm', {email, code})
// Once confirmed take user to login form

vue-customizing-amplify-authentication-vuex-module-api.js

Other Actions

  • fetchUser attempts to refresh the user state from Cognito. Should only call once on application initialize, or if you want to refresh the token
  • logout will ensure the user has logged out

Handling Authentication Changes:

Most cases will require logic run when a user gets logged in or out. You can add this to main.js or app.vue then choose to redirect the user, load components, etc on change.

{
  watch: {
    '$store.state.account.authorized': async function(n, o){
      if(n, o){
        //logic for user logging in
        await this.$api('user', 'signin') //example for logging in user to server
        if(this.$route.path !== '/' || !o)
          this.$router.push('/')
      }else{
        //logic for user logging out
        if(this.$route.path !== '/')
          this.$router.push('/')
      }
    },
  },
  // attempt to login user from session
  async created(){
    await store.dispatch('account/fetchUser')
  },
}

vue-customizing-amplify-authentication-handling-authentication-changes.js

Fetching the JWT (JSON Web Token)

You may need to obtain the JWT for interacting with a server or API. Here is some example code to fetch it:

// Dependencies ===============

  import {Auth} from 'aws-amplify'

// Function ===================

  async function getJWT(state){

    if(state.account.authorized){
      const session = await Auth.currentSession()
      const jwt = session.idToken.jwtToken
    }else{
      //handle not logged in
    }
  }

vue-customizing-amplify-authentication-fetching-jwt.js

The state is passed to the method purely to check if the user is currently authorized, this can be handled differently.

Extending Authentication to a Server API

Very soon I will release an article on:

  • Using Axios including attaching authorization headers
  • Express middleware for authorizing API requests
  • Auto handling expired tokens

A Complete Sign-in Component Using Vuetify

This lengthy example component handles all operations on one page (login, signup, and email validation).

This would be easy to customize for your Cognito setup and port to Bootstrap/Element/whatever framework you use.

<template><v-layout row wrap class="pt-4">

<!-- login -->

  <v-flex md6 v-if="!confirm" class="pa-3">
    <section title="Login to My Website">
      <v-form @submit.prevent.stop="userLogin()" ref="form-login" autocomplete="off">
        <v-text-field v-model="loginEmail" label="Email" required autocomplete="off" :rules="emailRules"></v-text-field>
        <v-text-field v-model="loginPassword" type="password" label="Password" required autocomplete="off" :rules="passwordRules"></v-text-field>
        <v-alert :value="$store.state.account.loginError" color="error" icon="far fa-exclamation-triangle" outline>{{ $store.state.account.loginError }}</v-alert>
        <v-btn color="primary" type="submit">Login</v-btn>
        <br><br>
        <span @click="confirm = true">Need to <span style="text-decoration: underline; color: #2196F3; cursor: pointer;">confirm</span> your email address?</span>
      </v-form>
    </section>
  </v-flex>

<!-- signup -->

  <v-flex md6 v-if="!confirm" class="pa-3">
    <section title="Signup to My Website">
      <v-form @submit.prevent.stop="userSignup()" ref="form-signup" autocomplete="off">
        <v-text-field v-model="signupEmail" label="Email address" required hint="We'll never share your email with anyone else." persistent-hint :rules="emailRules"></v-text-field>
        <v-text-field v-model="signupPassword" type="password" label="Password" required browser-autocomplete="new-password" :rules="passwordRules"></v-text-field>
        <v-text-field v-model="signupPasswordConfirm" type="password" label="Confirm Password" required browser-autocomplete="new-password" :rules="passwordRules"></v-text-field>
        <v-alert :value="signupError || $store.state.account.signupError" color="error" icon="far fa-exclamation-triangle" outline>{{ signupError ? signupError : $store.state.account.signupError }}</v-alert>
        <v-btn color="primary" type="submit">Signup</v-btn>
        <br><br>
        <v-alert :value="true" type="info" outline>You will be required to confirm your email by entering the code that has been emailed to you.</v-alert>
      </v-form>
    </section>
  </v-flex>

<!-- confirm -->

  <v-flex md2 style="padding: 5%;" v-if="confirm"/>
  <v-flex md8 class="pa-3" v-if="confirm">
    <section title="Confirm Your Email">
      <v-form @submit.prevent.stop="userConfirm()" ref="form-confirm" autocomplete="off">
        <v-text-field v-model="confirmEmail" label="Email address" required browser-autocomplete="off" :rules="emailRules"></v-text-field>
        <v-text-field v-model="confirmConfirmation" type="text" browser-autocomplete="new-password" label="Confirmation Number" required hint="Check your email (and spam folder) for a confirmation Number." persistent-hint :rules="confirmationRules"></v-text-field>
        <v-alert v-if="$store.state.account.confirmError" color="error" icon="far fa-exclamation-triangle" outline>{{ $store.state.account.confirmError }}</v-alert>
        <v-btn color="primary" type="submit">Confirm</v-btn>
        <br><br>
        <v-alert :value="true" type="info" outline>You will need to login after confirming your email.</v-alert>
        <br>
        <span @click.stop="confirm = false" style="cursor: pointer;">Need to <span style="text-decoration: underline; color: #2196F3; cursor: pointer;">login or signup</span>?</span>
      </v-form>
    </section>
  </v-flex>
  <v-flex md2 style="padding: 5%;" v-if="confirm"/>

  <v-progress-linear :indeterminate="true" color="secondary" v-if="loading"></v-progress-linear>

</v-layout></template>

<script>
// Dependencies ===============
  import section from '@/components/section'
// Core =======================
let component = {
  data: () => ({
    loginEmail: '',
    loginPassword: '',
    confirmEmail: '',
    confirmConfirmation: '',
    signupEmail: '',
    signupPassword: '',
    signupPasswordConfirm: '',
    signupError: '',
    loading: false,
    passwordRules: [
      v => !!v || 'Password is required',
      v => (v && v.length >= 8) || 'Password must be at least 8 characters',
    ],
    emailRules: [
      v => !!v || 'E-mail is required',
      v => /[email protected]+/.test(v) || 'E-mail must be valid',
    ],
    confirmationRules: [
      v => (v && v.length >= 5) || 'Verification code must be at least 5 numbers',
    ],
  }),
  computed: {
    confirm: {
      get(){
        return this.$store.state.account.confirm
      },
      set(value){
        this.$store.commit('account/confirm', value)
      },
    },
  },
  methods: {
    async userLogin(){
      if(!this.$refs['form-login'].validate())
        return
      this.loading = true
      await this.$store.dispatch('account/login', {email: this.loginEmail, password: this.loginPassword})
      this.loading = false
      return false
    },
    async userSignup(){
      if(!this.$refs['form-signup'].validate())
        return
      if(this.signupPassword !== this.signupPasswordConfirm){
        this.signupError = 'Confirmation password must match'
        return
      }
      this.signupError = ''
      this.loading = true
      this.confirmEmail = this.signupEmail
      await this.$store.dispatch('account/signup', {email: this.signupEmail, password: this.signupPassword})
      this.loading = false
      return false
    },
    async userConfirm(){
      if(!this.$refs['form-confirm'].validate())
        return
      this.loading = true
      await this.$store.dispatch('account/confirm', {email: this.confirmEmail, code: this.confirmConfirmation})
      this.loading = false
      return false
    },
    noAutoComplete(){
      this.$el.querySelectorAll('input[type="text"][autocomplete="off"').forEach(it => {
        it.setAttribute('autocomplete', 'new-password')
      })
    },
  },
  components: {
    section,
  },
  async created(){
    this.confirm = this.$store.state.account.user && this.$store.state.account.user.attributes && !this.$store.state.account.user.attributes.email_verified
  },
  mounted(){
    this.noAutoComplete()
  },
}
// Export =====================
export default component
</script>

<style scoped>
  button{
    background-color: #14A3F2;
    font-weight: 500;
    letter-spacing: -.5px;
  }
  div.alert{
    color: #990000;
  }
</style>

vue-customizing-amplify-authentication-example-component.vue

Component Notes

  • The section component is a simple card view with a title
  • I attempt to turn off browser autocomplete

Computed Confirmation Value From State.account

The computed confirmation variable tells the sign-in component if the user wants to confirm their email address (with the verification code sent). This happens when the user has just signed up or when the user has returned from checking their email for the verification code. Once verified, the user can log in.

Error Handling

There are two levels of error handling:

  1. If the field rules are not passed when submitting forms
  2. Errors passed by the Amplify library from the Cognito Service

The errors are then displayed on the components until a successful submittal.

That’s all for now, let me know if I am missing something or you want more information on anything.

✅ 30s ad

Vue.js 2 Academy: Learn Vue Step by Step

Vue.js 2 Essentials: Build Your First Vue App

Nuxt: Supercharged Vue JS

Vue.js Fast Crash Course

Vue JS 2: From Beginner to Professional (includes Vuex)