Skip to content

Rails GraphQL authentication from scratch #2

Published: at 12:00 AM

Welcome back to my series how to develop GraphQL API for rails applications from scratch.

Authentication - signIn method

To authenticate our users, we need to add jwt gem to our Gemfile

$ bundle add jwt

and create app/models/auth_token.rb

class AuthToken
  def self.key
    Rails.application.credentials.jwt_secret
  end

  def self.token(user)
    payload = {user_id: user.id}
    JWT.encode(payload, key)
  end

  def self.verify(token)
    result = JWT.decode(token, key)[0]
    User.find_by(id: result["user_id"])
  rescue JWT::VerificationError, JWT::DecodeError
    nil
  end
end

How does it work. We use jwt_secret which is stored in rails credentials to create a token which stores user_id and to find User record by token.

Let’s create our jwt_secret for development and test environments

$ rails credentials:edit --environment=development
$ rails credentials:edit --environment=test

and add the following line

jwt_secret: "secret"

Btw, don’t forget to generate secure keys using

$ rails secret

Let’s test it out.

AuthToken.key # "secret"

user = User.create(email: FFaker::Internet.email, password: SecureRandom.hex, first_name: FFaker::Name.first_name, last_name: FFaker::Name.last_name )

AuthToken.token(user) # "token-secret-value"

AuthToken.verify("token-secret-value") # returns User instance

Next, let’s implement current_user method. We need to update app/controllers/graphql_controller.rb

class GraphQLController < ApplicationController

 protect_from_forgery with: :null_session # <-- uncomment this line

  def execute
    variables = prepare_variables(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]
    context = {
      current_user: current_user # <-- add this line
    }
    result = GraphqlFromScratchSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
    render json: result
  rescue => e
    raise e unless Rails.env.development?
    handle_error_in_development(e)
  end

############### Add this method

  def current_user
    return nil if request.headers["Authorization"].blank?
    token = request.headers["Authorization"].split(" ").last
    return nil if token.blank?
    AuthToken.verify(token)
  end

###############

end

We need to create our mutation in app/graphql/mutations/users/sign_in.rb

module Mutations::Users
  class SignIn < Mutations::BaseMutation
    graphql_name "signIn"

    argument :email, String, required: true
    argument :password, String, required: true

    field :user, Types::UserType, null: true
    field :token, String, null: true

    def resolve(email:, password:)
      user = User.find_by(email:)
      errors = {}

      if user&.authenticate(password)
        context[:current_user] = user
        token = AuthToken.token(user)

        {token: AuthToken.token(user), user:, success: true}
      else
        user = nil
        context[:current_user] = nil

        raise GraphQL::ExecutionError, "Incorrect Email/Password"
      end
    end
  end
end

and add it to app/graphql/types/mutation_type.rb

module Types
  class MutationType < Types::BaseObject
    field :sign_up, mutation: Mutations::Users::SignUp
    field :sign_in, mutation: Mutations::Users::SignIn # <-- add this line
  end
end

Let’s try it in our GraphQL console

mutation {
  signIn(input: { email: "test@example.com", password: "1234567890" }) {
    user {
      id
      email
      name
    }
    success
    token
  }
}

And you’ll see a result

{
  "data": {
    "signIn": {
      "user": {
        "id": "9a7fb463-2493-48f6-8641-509f58c9b47f",
        "email": "test@example.com",
        "name": "Alexey Poimtsev"
      },
      "success": true,
      "token": "correct-token"
    }
  }
}

In case of wrong email/password

mutation {
  signIn(input: { email: "test@example.com", password: "111" }) {
    user {
      id
      email
      name
    }
    success
    token
  }
}

You’ll see an error message

{
  "data": {
    "signIn": null
  },
  "errors": [
    {
      "message": "Incorrect Email/Password",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": ["signIn"]
    }
  ]
}

Don’t forget to write specs spec/graphql/mutations/users/sign_in_spec.rb

require "rails_helper"

RSpec.describe "#signIn mutation" do
  before do
    @password = SecureRandom.hex
    @user = FactoryBot.create(:user, email: "user@example.com", password: @password)
  end

  let(:mutation) do
    <<~GQL
      mutation signIn($email: String!, $password: String!) {
        signIn(input: {
          email: $email
          password: $password
        }) {
          user {
            id
            email
            name
          }
          token
        }
      }
    GQL
  end

  it "is successful with correct email and password" do
    result = GraphqlFromScratchSchema.execute(mutation, variables: {
      email: "user@example.com",
      password: @password
    })

    expect(result.dig("data", "signIn", "errors")).to be_nil
    expect(result.dig("data", "signIn", "user", "email")).to eq("user@example.com")
    expect(result.dig("data", "signIn", "user", "id")).to be_present
    expect(result.dig("data", "signIn", "token")).to be_present
  end


  it "fails with wrong password" do
    result = GraphqlFromScratchSchema.execute(mutation, variables: {
      email: "user@example.com",
      password: "wrong-password"
    })

    expect(result.dig("data", "signIn", "user", "id")).to be_nil
    expect(result.dig("data", "signIn", "token")).to be_nil
    expect(result.dig("errors", 0, "message")).to eq("Incorrect Email/Password")
  end
end

Let’s break for a while to improve our code a bit.

Improvements - whoAmI method, inflections and GraphQL schema dump

Let’s add a helper method to easily test our authentication. Add following lines to app/graphql/types/query_type.rb (you can replace test_field method)

field :who_am_i, String, null: false,
  description: "Who am I"
def who_am_i
  "You've authenticated as #{context[:current_user].presence || "guest"}."
end

If you’re not authenticated

{
  whoAmI
}

you’ll see

{
  "data": {
    "whoAmI": "You've authenticated as guest."
  }
}

But if you use correct token

{
  "Authorization": "correct-token"
}

You’ll see

{
  "data": {
    "whoAmI": "You've authenticated as Alexey Poimtsev."
  }
}

Let’s play with inflections. Open file config/initializers/inflections.rb and make in looks like

ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym "RESTful"
  inflect.acronym "GraphQL" # <-- add this line
end

Now, we can rename in app/controllers/graphql_controller.rb class name GraphqlController to GraphQLController. Looks better, isn’t it? But don’t forget to rename every Graphql string in class names to GraphQL.

Let’s add rake task for schema dump. I’ve created lib/tasks/graphql.rake with following code

namespace :graphql do
  desc "Dump GraphQL schema"
  task dump_schema: :environment do
    # Get a string containing the definition in GraphQL IDL:
    schema_defn = GraphQLFromScratchSchema.to_definition
    # Choose a place to write the schema dump:
    schema_path = "app/graphql/schema.graphql"
    # Write the schema dump to that file:
    File.write(Rails.root.join(schema_path), schema_defn)
    puts "Updated #{schema_path}"
  end
end

and added spec in spec/graphql/schema_spec.rb

require "rails_helper"

RSpec.describe "GraphQL schema" do
  it "must be reflected in the .graphql file" do
    current_defn = GraphQLFromScratchSchema.to_definition
    printout_defn = File.read(Rails.root.join("app/graphql/schema.graphql"))
    assert_equal(current_defn, printout_defn, "Update the printed schema with `bundle exec rake dump_schema`")
  end
end

Now, with

$ rake graphql:dump_schema

I’ll have updated schema in app/graphql/schema.graphql and specs will remind me to update it.

If you like this post, you can hire me as an independent consultant for your project.
Just drop me a message using my contacts on the About me page