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: