Devise JWT Authentication in Rails
Here's one approach to adding API authentication to a Rails application that's already setup with [Devise](https://github.com/heartcombo/devise) and [Cancancan](https://github.com/CanCanCommunity/cancancan). We'll add a Warden strategy to process a Token if it's passed in the request.
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: :uuidto theuser_idsince I'm using UUIDs. - I changed the 
deletedcolumn tonull: 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_resourceand remove the generated loading. - Don't accept 
tokenparameter for tokens. - Update controller test to use login & factory_bot.
 - Update views to match site
 - Add 
Tokenmodel to Cancancan Abilities. - Change create method in the controller to use the 
make_tokenmethod.@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: