Rails api auth with Grape and Devise JWT

radinreth

radin reth

Posted on May 3, 2022

Rails api auth with Grape and Devise JWT

I am currently working on developing and api using grape and devise jwt for user user authentication.

Configure devise jwt is pretty straightforward all you need to do is just follow the instruction in the readme.

with Grape

Install gems

gem 'grape'
gem 'devise'
gem 'devise-jwt'
Enter fullscreen mode Exit fullscreen mode

install the gems

bundle install
Enter fullscreen mode Exit fullscreen mode

in app/api/api.rb

class Api < Grape::API
  helpers AuthHelpers
  helpers do
    def unauthorized_error!
      error!('Unauthorized', 401)
    end
  end

  mount V1::UserRegistrationApi
end
Enter fullscreen mode Exit fullscreen mode

in app/api/auth_helpers.rb

module AuthHelpers
  def current_user
    decoder = Warden::JWTAuth::UserDecoder.new
    decoder.call(token, :user, nil)
  rescue
    unauthorized_error!
  end

  def token
    auth = headers['Authorization'].to_s
    auth.split.last
  end
end
Enter fullscreen mode Exit fullscreen mode

Currently, there 3 revocation strategies that devise jwt provide such as jtimatcher, denylist and allowlist. but for now I am using allowlist strategy.

in app/models/user.rb

class User < ApplicationRecord
include Devise::JWT::RevocationStrategies::Allowlist

devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :jwt_authenticatable, jwt_revocation_strategy: self
end
Enter fullscreen mode Exit fullscreen mode

Next, we need to generate AllowlistedJwt model

bin/rails g model AllowlistedJwt
Enter fullscreen mode Exit fullscreen mode

Edit the migration file base on your need

class CreateAllowlistedJwts < ActiveRecord::Migration[6.1]
  def change
    create_table :allowlisted_jwts do |t|
      t.string :jti, null: false
      t.string :aud
      t.datetime :exp, null: false
      t.references :user, foreign_key: { on_delete: :cascade }, null: false

      t.timestamps
    end

    add_index :allowlisted_jwts, :jti, unique: true
  end
end
Enter fullscreen mode Exit fullscreen mode

you will need to provide jwt secret. you can generate your own secret key using rails then store it somewhere that you can access to it

bin/rails secret
Enter fullscreen mode Exit fullscreen mode

in config/initializers/devise.rb

Devise.setup do |config|
# other code
config.jwt do |jwt|
    jwt.secret = Rails.application.credentials.devise_jwt_secret_key!
    jwt.expiration_time = 3600
  end
end
Enter fullscreen mode Exit fullscreen mode

in app/api/v1/user_registration_api.rb

module V1
  class UserRegistrationApi < Grape::API
    namespace :user do
      namespace :register do
        before do
          @user_mobile_number = UserRegistrationWithMobileNumberService.new params[:mobile_number]
        end

        # set response headers
        after do
          header 'Authorization', @user_mobile_number.token
        end

        post do
          @user_mobile_number.register
        end
      end

      put :verify do
        current_user.verify(params[:code])
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

in app/services/user_registration_with_mobile_number_service.rb

class UserRegistrationWithMobileNumberService
  attr_reader :mobile_number, :token

  def initialize(mobile_number)
    @mobile_number = mobile_number
  end

  def register
    user = User.find_or_initialize_by mobile_number: mobile_number
    if user.save
      @token, payload = Warden::JWTAuth::UserEncoder.new.call(user, :user, nil)
      user.on_jwt_dispatch(@token, payload)
      # TODO: UserRegistrationJob.perform_later(user.id)
    end

    user
  end
end
Enter fullscreen mode Exit fullscreen mode

in spec/api/v1/user_registration_api_spec.rb

RSpec.describe V1::UserRegistrationApi, '/api/v1/user/register' do
  let(:mobile_number) { '01234567' }

  context 'with phone number' do
    it 'creates new user' do
      expect do
        post '/api/v1/user/register', params: { mobile_number: mobile_number }
      end.to change(User, :count).by 1
    end

    it 'responses the new created user' do
      post '/api/v1/user/register', params: { mobile_number: mobile_number }

      expect(json_body).to include mobile_number: mobile_number
      expect(json_body).to include status: 'pending'
      expect(json_body[:code]).to be_present
    end

    it 'responses with jwt authorization token' do
      post '/api/v1/user/register', params: { mobile_number: mobile_number }

      expect(response.headers['Authorization']).to match /(^[\w-]*\.[\w-]*\.[\w-]*$)/
    end

    context 'when mobile number is already registered' do
      let!(:user) { create(:user, mobile_number: mobile_number)}

      it 'responses with jwt token' do
        post '/api/v1/user/register', params: { mobile_number: mobile_number }

        expect(jwt_token).to match /(^[\w-]*\.[\w-]*\.[\w-]*$)/
      end
    end
  end

  context 'when confirm' do
    before do
      post '/api/v1/user/register', params: { mobile_number: mobile_number }
    end

    context 'with correct code' do
      it 'changes status from pending to confirmed' do
        put '/api/v1/user/verify', params: { code: response.body['code'] }, headers: { 'Authorization': "Bearer #{jwt_token}" }

        expect(json_body(reload: true)[:status]).to eq 'confirmed'
      end
    end

    context 'with wrong code' do
      it 'unable to confirm' do
        put '/api/v1/user/verify', params: { code: 'wrong-code' }, headers: { 'Authorization': "Bearer #{jwt_token}" }

        expect(json_body(reload: true)[:status]).to eq 'pending'
      end
    end

    context 'without authorized jwt token header' do
      it 'responses unauthorized' do
        put '/api/v1/user/verify', params: { code: json_body[:code] }

        expect(response).to be_unauthorized
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Additional notes:
Try it out in the console

newuser = UserRegistrationWithMobileNumberService.new('+85593555115')
registered_user = newuser.register
jwt = registered_user.allowlisted_jwts.last
payload = { jti: jwt.jti, aud: jwt.aud }

User.jwt_revoked? payload, registered_user
User.revoke_jwt payload, registered_user
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
radinreth
radin reth

Posted on May 3, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related