Profile picture Schedule a Meeting
c a n d l a n d . n e t

Devise JWT Authentication in Rails

Dusty Candland | | rails, devise, jwt, cancancan

Here's one approach to adding API authentication to a Rails application that's already setup with Devise and Cancancan. We'll add a Warden strategy to process a Token if it's passed in the request.

Setup

Add the JWT gem to the Gemfile.

gem 'jwt'`

Create an environment variable for the JWT_SECRET. Could use Rails secrets as well.

export JWT_SECRET="some_secret_for_jwt"

Create a JsonToken class to create tokens. Customize as needed for the usecase.

# app/lib/json_token.rb
module JsonToken
  extend self

  ALG = 'HS256'.freeze
  SEC = ENV['JWT_SECRET'].freeze

  def encode(payload, _expiration = nil)
    # expiration ||= Rails.application.secrets.jwt_expiration_hours
    # payload = payload.dup
    # payload['exp'] = expiration.to_i.hours.from_now.to_i

    JWT.encode payload, SEC, ALG
  end

  def decode(token)
    decoded_token = JWT.decode token, SEC, ALG
    decoded_token.first
  rescue
    nil
  end
end

Store Tokens

First, we need a way for users to create tokens. I'll start with a scaffold for a Token model.

rails g scaffold token name token deleted:boolean user:references --no-assets --no-scaffold-stylesheet --no-jbuilder --no-javascripts --no-helper

After this, change the migration before running migrate.

  • I added type: :uuid to the user_id since I'm using UUIDs.
  • I changed the deleted column to null: false, default: true.
rails db:migrate

Update the Token class with some validation and a method to make tokens.

class Token < ApplicationRecord
  belongs_to :user

  validates :name, presence: true
  validates :token, presence: true

  scope :active, -> { where('deleted = false') }

  scope :deleted, -> { where('deleted = true') }

  def make_token
    self.token = JsonToken.encode({user_id: user_id})
    self
  end

  def to_s
    name
  end
end

And update the Association in the User class.

class User < ApplicationRecord
  ...
  has_many :tokens
  ...
end

Some other fixes to the generated code. These vary between apps. Here's a quick list of changes.

  • Remove JSON support from the controller. Not sure I needed to do this.
  • Change to Cancancan to load_and_authorize_resource and remove the generated loading.
  • Don't accept token parameter for tokens.
  • Update controller test to use login & factory_bot.
  • Update views to match site
  • Add Token model to Cancancan Abilities.
  • Change create method in the controller to use the make_token method.
    @token = @current_user.tokens.build(token_params).make_token

Authentication

Create a strategy for Warden to use. This will look for a bearer token in the Authorization header. If that works, it will continue as a logged in user, just like we're already expecting.

# config/initializers/devise/strategies/json_web_token.rb
module Devise
  module Strategies
    class JsonWebToken < Base
      def valid?
        bearer_header.present?
      end

      def authenticate!
        return if no_claims_or_no_claimed_user_id

        user = User.find_by_id(claims['user_id'])
        request.params[:claims] = claims
        success! user
      end

      protected

      def bearer_header
        request.headers['Authorization'].to_s
      end

      def no_claims_or_no_claimed_user_id
        !claims || !claims.has_key?('user_id')
      end

      private

      def claims
        @claims ||= get_claims
      end

      def get_claims
        strategy, token = bearer_header.split(' ')

        return nil if (strategy || '').downcase != 'bearer'

        JsonToken.decode(token) rescue nil
      end
    end
  end
end

Next tell Devise, Warden actually, that we have a strategy to use add.

# config/initializers/devise.rb
Devise.setup do |config|
  ...

  config.warden do |manager|
    # Registering your new Strategy
    manager.strategies.add(:jwt, Devise::Strategies::JsonWebToken)

    # Adding the new JWT Strategy to the top of Warden's list,
    # Scoped by what Devise would scope (typically :user)
    manager.default_strategies(scope: :user).unshift :jwt
  end
end

Authorization

I added an api role to my users and setup users with role to be able to manage tokens using Cancancan. This gets into a lot of other details about authorization I'm leaving out of this post.

Tests

We should now be able to make a request to the API using a Token. First create a token using the app.

curl -v -H "Accept: application/json" -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZGU1ODljNjgtYjFmYi00ZjM5LTgyZjAtYjdlNTEyZDliM2EyIn0.LorKJ9KQnzsquRo7fAoBbBG9UmrEFn_HjyRwYPfkeZk" "http://localhost:3000/brands"

Example controller test.

require "test_helper"

class BrandsControllerTest < ActionDispatch::IntegrationTest
  setup do
    @user = create(:user, roles: [:brand])
    @brand = create(:brand, users: [@user])
    @token = create(:token, user: @user)
    @token.make_token.save!
  end

  def headers
    {
      "Content-Type": "application/json",
      "Accept": "application/json",
      "Authorization": "Bearer #{@token.token}",
    }
  end

  test "should get index" do
    get brands_url, headers: headers
    assert_response :success
    assert JSON.parse(response.body)
  end

  test "should create brand" do
    assert_difference("Brand.count") do
      post brands_url, headers: headers, params: {brand: attributes_for(:brand)}.to_json
    end

    brand = Brand.all.order(created_at: :desc).first
    assert_response :created

    @user = @user.reload
    assert @user.brands.include?(brand)
    assert_equal @user.brands.count, 2
  end

  test "should show brand" do
    get brand_url(@brand), headers: headers
    assert_response :success
    assert json = JSON.parse(response.body)

    keys = ["id", "name", "property_id", "created_at", "updated_at", "uri"]
    assert_equal keys, keys & json.keys
    assert_equal json.keys, json.keys & keys
  end

  test "should update brand" do
    patch brand_url(@brand), params: {brand: attributes_for(:brand)}.to_json, headers: headers
    assert_response :success
  end

  test "should destroy brand" do
    assert_difference("Brand.count", -1) do
      delete brand_url(@brand), headers: headers
    end

    assert_response :no_content
  end
end

Webmentions

These are webmentions via the IndieWeb and webmention.io. Mention this post from your site: