DEV Community

Alexey Poimtsev
Alexey Poimtsev

Posted on • Edited on • Originally published at alec-c4.com

Rails GraphQL authentication from scratch #2

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and add the following line

jwt_secret: "secret"
Enter fullscreen mode Exit fullscreen mode

Btw, don't forget to generate secure keys using

$ rails secret
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Let's try it in our GraphQL console

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

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In case of wrong email/password

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

You'll see an error message

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If you're not authenticated

{
  whoAmI
}
Enter fullscreen mode Exit fullscreen mode

you'll see

{
  "data": {
    "whoAmI": "You've authenticated as guest."
  }
}
Enter fullscreen mode Exit fullscreen mode

But if you use correct token

{
  "Authorization": "correct-token"
}
Enter fullscreen mode Exit fullscreen mode

You'll see

{
  "data": {
    "whoAmI": "You've authenticated as Alexey Poimtsev."
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now, with

$ rake graphql:dump_schema
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)