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: :uuid
to theuser_id
since I'm using UUIDs. - I changed the
deleted
column 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_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: