Make it Secure 2, GraphQL by Rails and Devise

Published: Mar 23, 2023

This is the second post about securing Rails GraphQL API app. Most of conceptual explanations are in the previous post, Make it Secure, GraphQL by Rails. This memo is focusing on how to add the authentication using Devise gem (https://github.com/heartcombo/devise).

The GraphQL API itself is the same as the previous one. The GraphQL app in the previous post uses Global ID and implements the user authentication from scratch. The Devise gem covers many of those. However the odds are: the Devise gem is for a traditional web application, so the GraphQL API only app can’t use as is. Luckily, people have already tried the token based authentication by Devise and established a couple of ways to do that. A combination with JWT (Jason Web Token, https://github.com/jwt/ruby-jwt) is one, while with Devise Token Auth (https://github.com/lynndylanhurley/devise_token_auth) is another one. This memo uses GraphQLDevise gem (https://github.com/graphql-devise/graphql_devise), which is implemented on top of Devise Token Auth.

Versions:

  • Ruby: 3.2.1
  • Rails: 7.0.4.3

Create Rails App

The Rails app creation is same as previous two posts. However, the app here introduces Devise gem, so this memo starts from creating the app for clarity.

Create an option list file, for example, ./.railsrc, with the following content.

--api
--skip-action-mailer
--skip-action-mailbox
--skip-action-cable
--skip-action-text
--skip-active-job
--skip-active-storage
-T

This app skips email based user registration provided by Devise gem. For this reason, the option list has --skip-action-mailer. The option list above creates much smaller API only Rails app.

Run the command to create the app.

$ rails new mini-blog-2 --rc=./.railsrc

Setup graphql_devise gem

The app is going to use graphql_devise gem for the authentication. The first step is to add and initialize the gem.

$ cd mini-blog-2
$ bundle add graphql_devise

Above command installs graphql_devise gem as well as graphql, devise, devise_auth_token gems as dependencies.

The next step depends on the design decision, whether to create a new GraphQL path for the authentication or to use an existing path. If the new path is chosen, GraphQL paths will have /graphql and /graphql_auth. If the existing path is chosen, GraphQL path will be only /graphql as normal GraphQL app has. To use existing path, graphql_devise initializer needs an existing GraphQL schema as a mount point. The choice here is to mount on the exising schema. For this reason, the second step is to initialize GraphQL gem.

$ rails g graphql:install

Above command generates the schema MiniBlog2Schema in the app/graphql/mini_blog2_schema.rb. This is the mount point. The graphql_devise initialization includes Devise model creation, so it needs model name for the authentication. The model name is typically User.

$ rails g graphql_devise:install User --mount MiniBlog2Schema

Above command shows messages from Devise gem in addition to creating a bunch of files. As for GraphQL side, include GraphqlDevise::SetUserByToken is added to app/controllers/application_controller.rb, and a schema plugin is added to app/graphql/mini_blog2_schema.rb.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  include GraphqlDevise::SetUserByToken
end
# app/graphql/mini_blog2_schema.rb
class MiniBlog2Schema < GraphQL::Schema
  use GraphqlDevise::SchemaPlugin.new(
    query:            Types::QueryType,
    mutation:         Types::MutationType,
    resource_loaders: [
      GraphqlDevise::ResourceLoader.new(User)
    ]
  )
  mutation(Types::MutationType)
  query(Types::QueryType)

  # more code here

end

The migration file for the user model has been created as well, so run the migration.

$ rails db:migrate

Additional graphql_devise configuration

We are almost there. Since the app mounts the authentication path to the existing GraphQL path, some more configurations are required.

Add include GraphqlDevise::FieldAuthentication in the file, app/graphql/types/base_field.rb. Add gql_devise_context(User) in the file, app/controller/graphql_controller.rb, to receive user info from GraphQL queries/mutations.

# app/graphql/types/base_field.rb.
module Types
  class BaseField < GraphQL::Schema::Field
    include GraphqlDevise::FieldAuthentication

    argument_class Types::BaseArgument
  end
end
# app/controller/graphql_controller.rb
def execute
  variables = prepare_variables(params[:variables])
  query = params[:query]
  operation_name = params[:operationName]
  context = gql_devise_context(User)
  result = MiniBlog2Schema.execute(query, variables: variables, context: context, operation_name: operation_name)
  render json: result
rescue StandardError => e
  raise e unless Rails.env.development?
  handle_error_in_development(e)
end

Make Mutation Queries to Register and Login

For now, we can register a user and login. Let’s try. As a GraphQL client, Insomnia (https://insomnia.rest/) is used since Insomnia has a nice UI for HTTP request/response headers.

To register the user, make mutation query below.

mutation register {
	userRegister(
		email: "finn.smith@example.com"
		password: "password!"
		passwordConfirmation: "password!"
	) {
		authenticatable {
			email
		}
		credentials{
			accessToken
			client
			uid
		}
	}
}

Above query sets only required fields. Other fields can be seen in Insomnia’s Schema pane. The response fields are the same. The accessToken, client and uid will be used for later queries, so those are requested.

img: insomnia devise user register

Let’s check HTTP response headers. As in below, authentication related headers can been seen.

img: insomnia devise user register response header

To login as a registered user, make mutation query below.

mutation login {
	userLogin(
		email: "finn.smith@example.com"
		password: "password!"
	) {
		authenticatable {
			email
		}
		credentials{
			accessToken
			client
			uid
		}
	}
}

Successful login returns the same result as the registration.

img: insomnia devise user login img: insomnia devise user login response header

User and Post Models

If we imagine to create a blog site, minimum models would be users and posts. Those two models should have a relation: a user has many posts.

The user model is already created when graphql_devise was initialized. The post model should be created, but nothing special.

$ rails g model Post user:references title:string{50} content:text

We want to add some non-null constraints to user and post models. Create migrations:

$ rails g migration ChangeEmailNullOnUsers
$ rails g migration ChangeTitleContentNullOnPosts

Edit migration files:

#  db/migrate/[DATE TIME]_change_email_null_on_users.rb
class ChangeEmailNullOnUsers < ActiveRecord::Migration[7.0]
  def change
    change_column_null :users, :email, false
  end
end

#  db/migrate/[DATE TIME]_change_title_content_null_on_posts.rb
class ChangeTitleContentNullOnPosts < ActiveRecord::Migration[7.0]
  def change
    change_column_null :posts, :title, false
    change_column_null :posts, :content, false
  end
end

Run migration:

$ rails db:migrate

Update models:

# app/models/user.rb
class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  include GraphqlDevise::Authenticatable

  has_many :posts, dependent: :destroy

  validates :email, presence: true, uniqueness: true
end

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user

  validates :title, presence: true
  validates :content, presence: true
end

GraphQL Queries

To make queries, we need GraphQL types. Generate user and post types.

$ rails g graphql:object user
$ rails g graphql:object post

Now, we got two types:

  • app/graphql/types/user_type.rb
  • app/graphql/types/post_type.rb

We need query definitions for both user and post. Create resolvers as in below:

# app/graphql/resolvers/user_resolver.rb
module Resolvers
  class UserResolver < GraphQL::Schema::Resolver
    type [Types::UserType], null: false

    argument :id, Int, required: false

    def resolve(**kwargs)
      if kwargs[:id]
        [User.find(kwargs[:id])]
      else
        User.all
      end
    end
  end
end

# app/graphql/resolvers/post_resolver.rb
module Resolvers
  class PostResolver < GraphQL::Schema::Resolver
    type [Types::PostType], null: false

    argument :user_id, Int, required: false

    def resolve(**kwargs)
      if kwargs[:user_id]
        Post.where(user: kwargs[:user_id]).all
      else
        Post.all
      end
    end
  end
end

Update query_types to include user and post resolvers with authenticate option false. An assumption here is that we don’t need to authenticate for queries.

# app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    # Add `node(id: ID!) and `nodes(ids: [ID!]!)`
    include GraphQL::Types::Relay::HasNodeField
    include GraphQL::Types::Relay::HasNodesField

    # Add root-level fields here.
    # They will be entry points for queries on your schema.
    field :users, resolver: Resolvers::UserResolver, authenticate: false
    field :posts, resolver: Resolvers::PostResolver, authenticate: false
  end
end

An example query to get all users will be blow:

query users {
	users {
		id
		email
	}
}

img: insomnia devise users query

To make the query above, no authentication is required at all.

We can make post query as well, however, it returns an empty array at this moment.

GraphQL Mutations

The next is a create post mutation so that we can see some results from the post query.

$ rails g graphql:mutation_create post

Above generates app/graphql/mutations/post_create.rb file. After some editing, the post_create.rb looks like below:

# app/graphql/mutations/post_create.rb
module Mutations
  class PostCreate < BaseMutation
    description "Creates a new post"

    field :post, Types::PostType, null: false

    argument :title, String, required: true
    argument :content, String, required: true

    def resolve(**kwargs)
      kwargs[:user_id] = context[:current_resource].id
      post = ::Post.new(**kwargs)
      raise GraphQL::ExecutionError.new "Error creating post", extensions: post.errors.to_hash unless post.save

      { post: post }
    end
  end
end

Since mutation queries require authentication, the logged in user information is available in the context. The user id to create a post is retrieved from the authentication result.

Lastly, we need a little hack on Rails 7.0.4.3 and Devise 4.9.0. In general, API only web application don’t use cookie or session. However, Devise has been developed based on a traditional web application, so it still relies on the cookie. The issue is on-going and discussed at https://github.com/heartcombo/devise/issues/5443.

The workaround is adding below three lines to config/application.rb. The session store’s key is, in general, “[APP NAME]_session#{Rails.env}”.

# config/application.rb
# ...
# ...
module MiniBlog2
  class Application < Rails::Application
    # ...
    # ...
    config.session_store :cookie_store, key: "_mini-blog-2_session_#{Rails.env}"
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use config.session_store, config.session_options
  end
end

Everything is ready. It’s time to create some posts. As the application designed like this, we need to log in first. Try login mutation query.

mutation login {
	userLogin(
		email: "finn.smith@example.com"
		password: "password!"
	) {
		authenticatable {
			email
		}
		credentials{
			accessToken
			client
			uid
		}
	}
}

img: insomnia devise login for post

The successful login returns accessToken, client, and uid. These three are used to authenticate. The create post mutation query looks like below:

mutation post {
	postCreate(input: {
		title: "Hello World, Again",
		content: "This is the second post from Finn."
	}) {
		post {id userId title content}
	}
}

Set three user authentication values to the HTTP request header. Be careful, GraphQL result is accessToken, but HTTP request header is access-token. Now, we could create a post.

img: insomnia devise post mutation

img: insomnia devise post request headers

Add some more posts using different user’s authentication values.

To get all posts by all users, make below query.

query posts {
	posts {
		id
		userId
		title
		content
	}
}

img: insomnia devise all posts

To get all post of a specific user, make below query with user’s id.

query posts {
	posts(userId: 3) {
		id
		userId
		title
		content
	}
}

img: insomnia devise posts of a user

Code

The example Rails app code is on the GitHub repo. Please see https://github.com/yokolet/mini-blog-2.

Latest Posts

Vite + Vue + Bun on Rails

Vue.js is one of frontend frameworks gaining popularity among rapidly emerging JavaScript technologies. The combination of Vue.js and Rails is becoming more popular as well, however, Vue.js development on Rails is not so straightforward. The reason would be that Vue.js relies on Vite for a development environment such as HMR (Hot Module Replacement) and bundling.

Bun + React on Rails

In the frontend world, new technologies keep emerging rapidly these years. Still, React is a well-established and very popular frontend framework, Vue.js, Svelte, Astro and more frameworks are gaining popularity. Not just the frameworks, tools for a transpiler/bundler or sort are also under a rapid development. In JavaScript domain, esbuild, rollup, vite and some more are out.

Ruby on Rails Secrets Management

A web application needs various kinds of values, params and etc which should not be revealed to the public, say GitHub repo. For example, API keys, tokens, passwords, and endpoints, all those should be kept secret. Another important factor is that such secrets should be shared among the team members. Additionally, all those secrets should come along with the deployment.