JWT auth in your GraphQL Rails API


Quick introduction

Check these links first:

What is JWT?

https://en.wikipedia.org/wiki/JSON_Web_Token

What is GraphQL?

https://graphql.org/learn/

Let’s say you are working on some GraphQL based API, you already have devise and you want to implement JWT authentication.

As usual we can start from some gems.

Adding gems to your project

gem 'devise'
gem 'devise-token_authenticatable'

Next, we should define UserType with some basic fields. Let’s add authentication_token field and authentication_token method with special check for current user in context.

module Types
  class UserType < Types::BaseObject
    graphql_name "User"

    implements GraphQL::Types::Relay::Node
    global_id_field :id

    field :phone, String, null: false
    delegate :phone, to: :object

    field :lastName, String, null: false
    delegate :last_name, to: :object

    field :firstName, String, null: false
    delegate :first_name, to: :object

    field :email, String, null: false

    field :authentication_token, String, null: false
    def authentication_token
      if object.gql_id != context[:current_user]&.gql_id
        raise GraphQL::UnauthorizedFieldError,
              "Unable to access authentication_token"
      end

      object.authentication_token
    end
  end
end

Next step - migration, let’s add fields and index for Devise::TokenAuthenticatable gem.

class DeviseCreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      # Custom
      t.string :first_name, null: false
      t.string :last_name,  null: false
      t.string :phone,  null: false

      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      ## Recoverable
      t.string   :reset_password_token
      t.datetime :reset_password_sent_at

      ## Rememberable
      t.datetime :remember_created_at

      ## Trackable
      t.integer  :sign_in_count, default: 0, null: false
      t.datetime :current_sign_in_at
      t.datetime :last_sign_in_at
      t.inet     :current_sign_in_ip
      t.inet     :last_sign_in_ip

      t.text :authentication_token
      t.datetime :authentication_token_created_at

      t.timestamps null: false
    end

    add_index :users, :email,                unique: true
    add_index :users, :reset_password_token, unique: true
    add_index :users, :authentication_token, unique: true
    add_index :users, :phone,                unique: true
  end
end

Frontend app

Now, we can implement token related work on our Vue app. SignIn component with call to Apollo client and set token to localStorage in your browser.

<script>
import Alerts from '@/components/shared/alerts';
import _get from 'lodash/get';
import signIn from '@/mutations/signIn';
import { AUTH_TOKEN_KEY } from '@/configuration/appConstants';
import { mapMutations } from 'vuex';
export default {
  name: 'SignIn',
  components: { Alerts },
  data() {
    return {
      errors: [],
      form: {},
    };
  },
  methods: {
    ...mapMutations(['signIn']),
    handleSignIn() {
      signIn({
        apollo: this.$apollo,
        ...this.form,
      }).then(response => _get(response, 'data.signIn', {}))
      .then(response => {
        if(response.success) {
          const user = response.user;
          this.signIn(user);
          localStorage.setItem(AUTH_TOKEN_KEY, user.authenticationToken);
          this.$router.push({ name: 'home' });
        } else {
          this.errors = this.errorMessages(response.data.signIn.errors);
        }
      }).catch(error => {
        this.errors = [error];
      });
    },
  },
};
</script>

Same thing here SignUp client, and setting AUTH_TOKEN_KEY with authenticationToken token.

<script>
import Alerts from '../../shared/alerts';
import _get from 'lodash/get';
import signUp from '@/mutations/registerUser';
import { AUTH_TOKEN_KEY } from '@/configuration/appConstants';
import { mapMutations } from 'vuex';
export default {
  name: 'SignUp',
  components: { Alerts },
  data() {
    return {
      errors: [],
      form: {},
    };
  },
  methods: {
    ...mapMutations(['signIn']),
    handleSignUp() {
      signUp({
        apollo: this.$apollo,
        ...this.form,
      }).then(response => _get(response, 'data.registerUser', {}))
      .then(response => {
        if(response.success) {
          const user = response.user;
          this.signIn(user); // using the Vuex store
          localStorage.setItem(AUTH_TOKEN_KEY, user.authenticationToken);
          this.$router.push({ name: 'home' });
        } else {
          this.errors = this.errorMessages(response.data.registerUser.errors);
        }
      }).catch(error => {
        this.errors = [error];
      });
    },
  },
};
</script>

Let’s create a SignIn mutation which receive email and password as params.


import gql from 'graphql-tag';

const mutation = gql`
  mutation signIn($email: String!, $password: String!) {
    signIn(input: { email: $email, password: $password }) {
      user {
        id
        firstName
        lastName
        authenticationToken
      }
      success
      errors
    }
  }
`;

export default function signIn({
  apollo,
  email,
  password,
}) {
  return apollo.mutate({
    mutation,
    variables: {
      email,
      password,
    },
  });
}

And same thing for registerUser mutation, with user registration params.

import gql from 'graphql-tag';

const mutation = gql`
  mutation registerUser(
    $phone: String!,
    $firstName: String!,
    $lastName: String!,
    $email: String!,
    $password: String!,
  ) {
    registerUser(input: {
      phone: $phone,
      firstName: $firstName,
      lastName: $lastName,
      email: $email,
      password: $password,
    }) {
      user {
        id
        phone
        firstName
        lastName
        email
        authenticationToken
      }
      success
      errors
    }
  }
`;

export default function({
  apollo,
  phone,
  firstName,
  lastName,
  email,
  password,
}) {
  return apollo.mutate({
    mutation,
    variables: {
      phone,
      firstName,
      lastName,
      email,
      password,
    },
  });
}

Backend

Okay, next steps on backend side (Rails) and CORS configuration, simple thing and nothing special.

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:8080'

    resource '*',
      headers: :any,
      expose: ['access-token', 'expiry', 'token-type', 'Authorization'],
      credentials: true,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

SignOut mutation. On this step - just check for current_user and return MutationResult.

module Mutations
  class SignOut < Mutations::BaseMutation
    graphql_name "SignOut"

    field :user, Types::UserType, null: false

    def resolve
      user = context[:current_user]
      if user.present?
        success = user.reset_authentication_token!

        MutationResult.call(
          obj: { user: user },
          success: success,
          errors: user.errors
        )
      else
        GraphQL::ExecutionError.new("User not signed in")
      end
    end
  end
end

RegisterUser mutation - set current_user in a context and return MutationResult.

module Mutations
  class RegisterUser < Mutations::BaseMutation
    graphql_name "RegisterUser"

    argument :phone, String, required: true
    argument :first_name, String, required: true
    argument :last_name, String, required: true
    argument :email, String, required: true
    argument :password, String, required: true

    field :user, Types::UserType, null: false

    def resolve(args)
      user = User.create!(args)
      context[:current_user] = user
      MutationResult.call(
        obj: { user: user },
        success: user.persisted?,
        errors: user.errors
      )
    rescue ActiveRecord::RecordInvalid => invalid
      GraphQL::ExecutionError.new(
        "Invalid Attributes for #{invalid.record.class.name}: " \
        "#{invalid.record.errors.full_messages.join(', ')}"
      )
    end
  end
end

Done! Now we have working user login/register/logout workflow based on GraphQL and JWT.