JSON Web Token (JWT) and HTML Logins with Devise and Ruby on Rails 5

Brent Kearney
Jul 31 · 5 min read

July 2019

Implementing JWT logins with Rails 5 in API mode is a breeze, according to many blog posts. However, if you want to add JWT logins to an already functioning Rails 5 + Devise HTML app, that is an entirely different story. Here is one way to accomplish a “dual-login” setup.

1. Install devise-jwt

Add devise-jwt to your Gemfile, as per instructions.

Be sure to add a DEVISE_JWT_SECRET_KEY variable to your environment, set to at least 128 random characters. This will be the seed for encrypting and decrypting the authentication tokens. There are several ways to add environment variables to Rails.

2. Configure Devise & Warden for JWT

I’m using different login paths for API logins and HTML logins. This way, devise-jwt issues JWT auth tokens for API users but not for regular web users. Keeping the types of users separate allows me to add special requirements for API users — for example, that they be staff members.

# config/initializers/devise.rb
config.jwt do |jwt|
jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
jwt.dispatch_requests = [
['POST', %r{^/api/login$}],
['POST', %r{^/api/login.json$}]
]
jwt.revocation_requests = [
['DELETE', %r{^/api/logout$}],
['DELETE', %r{^/api/logout.json$}]
]
jwt.expiration_time = 1.day.to_i
jwt.request_formats = { api_user: [:json] }
end
config.skip_session_storage = [:http_auth]
config.navigational_formats = ['*/*', :html, :json]
  • dispatch_requests: the endpoint (URL/route) for logins, where devise-jwt will issue new authentication tokens. I want JSON users to visit /api/login to authenticate.
  • revocation_requests: the endpoint for logouts, where devise-jwt will revoke authentication tokens.
  • request_formats: the format of incoming requests that devise-jwt should pay attention to. In my app, I only want JSON web tokens to be issued for JSON requests (you could also add nil,which means unspecified format). For HTML requests, I want users to go to the web interface at a different endpoint, “/signin”, so if someone visits /api/login with an HTML request instead of JSON, I want devise-jwt to ignore it.

Also, configure warden:

# config/initializers/warden_auth.rb
Warden::JWTAuth.configure do |config|
config.secret = ENV['DEVISE_JWT_SECRET_KEY']
config.dispatch_requests = [
['POST', %r{^/api/login$}],
['POST', %r{^/api/login.json$}]
]
config.revocation_requests = [
['DELETE', %r{^/api/logout$}],
['DELETE', %r{^/api/logout.json$}]
]
end

3. Configure API login/logout routes

# config/routes.rb# Devise (login/logout) for HTML requests
devise_for :users, defaults: { format: :html },
path: '',
path_names: { sign_up: 'register' },
controllers: {
sessions: 'sessions',
registrations: 'registrations',
confirmations: 'confirmations'
}
devise_scope :user do
get 'sign_in', to: 'devise/sessions#new'
get 'register', to: 'devise/registrations#new'
post 'register', to: 'devise/registrations#create'
delete 'sign_out', to: 'devise/sessions#destroy'
get 'confirmation/sent', to: 'confirmations#sent'
get 'confirmation/:confirmation_token', to: 'confirmations#show'
patch 'confirmation', to: 'confirmations#create'
end
# API namespace, for JSON requests
namespace :api do
devise_for :users, defaults: { format: :json },
class_name: 'ApiUser',
skip: [:registrations, :invitations,
:passwords, :confirmations,
:unlocks],
path: '',
path_names: { sign_in: 'login',
sign_out: 'logout' }
devise_scope :user do
get 'login', to: 'devise/sessions#new'
delete 'logout', to: 'devise/sessions#destroy'
end
...
end

4. Update the User table and model

I choose to use the JTIMatcher revocation strategy, which stores a string in a column in the User table, that acts as part of the seed for creating the authorization token. Revoking the token is then accomplished by simply updating the User.jtiattribute.

I followed the given advice, and set a unique index in the jti column, which meant updating all previously existing User records in my database migration:

# db/migrations/20190626011110_add_jti_to_users
class AddJtiToUsers < ActiveRecord::Migration
def change
add_column :users, :jti, :string
# populate jti so we can make it not nullable
User.all.each do |user|
user.update_column(:jti, SecureRandom.uuid)
end
change_column_null :users, :jti, false
add_index :users, :jti, unique: true
end
end

Update the User model to ensure that the jti column is filled out at time of creation:

# app/models/user.rb
class User < ApplicationRecord
devise :registerable, :database_authenticatable,
:recoverable, :rememberable, :trackable, :validatable,
:lockable, :confirmable, :invitable
...

before_create :add_jti
def add_jti
self.jti ||= SecureRandom.uuid
end
...
end

5. Add an ApiUser model, as a sub-class of User

# app/models/api_user.rb
class ApiUser < User
include Devise::JWT::RevocationStrategies::JTIMatcher
devise :jwt_authenticatable, jwt_revocation_strategy: self
validates :jti, presence: true def generate_jwt
JWT.encode({ id: id,
exp: 5.days.from_now.to_i },
Rails.env.devise.jwt.secret_key)
end
end

6. Update ApplicationController

You need special treatment for JSON requests, since they don’t have session cookies, and we want to use the api_user scope for authentication.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception, unless: :json_request?
protect_from_forgery with: :null_session, if: :json_request?
skip_before_action :verify_authenticity_token, if: :json_request?
rescue_from ActionController::InvalidAuthenticityToken, with: :invalid_auth_token before_action :set_current_user, if: :json_request?
private
def json_request?
request.format.json?
end
# Use api_user Devise scope for JSON access
def authenticate_user!(*args)
super and return unless args.blank?
json_request? ? authenticate_api_user! : super
end
def invalid_auth_token
respond_to do |format|
format.html { redirect_to sign_in_path,
error: 'Login invalid or expired' }
format.json { head 401 }
end
end
# So we can use Pundit policies for api_users
def set_current_user
@current_user ||= warden.authenticate(scope: :api_user)
end

The set_current_user method ensures that the global @current_user variable is set using the api_user scope, for JSON requests. I use Pundit for access control in my app, which relies on that variable for its policies. This way, I have a single source of authorization policies for both JSON and HTML users.

7. Override API SessionsController

I could not get devise-jwt to revoke the JTI token with the default destroy method, so I did it myself with the custom one below. The default create method worked for me, but I have other conditions and custom responses, so I overrode that method too.

# app/controllers/api/sessions_controller.rb
class Api::SessionsController < Devise::SessionsController
skip_before_action :verify_signed_out_user
respond_to :json
# POST /api/login
def create
unless request.format == :json
sign_out
render status: 406,
json: { message: "JSON requests only." } and return
end
# auth_options should have `scope: :api_user`
resource = warden.authenticate!(auth_options)
if resource.blank?
render status: 401,
json: { response: "Access denied." } and return
end
sign_in(resource_name, resource)
respond_with resource, location:
after_sign_in_path_for(resource) do |format|
format.json { render json:
{ success: true,
jwt: current_token,
response: "Authentication successful"
}
}
end
end
# DELETE /api/logout.json
def destroy
super and return if params['Authorization'].blank?
user = ApiUser.find_by_jti(decode_token)
super and return if user.blank?
revoke_token(user)
super
end
private def decode_token
token = params['Authorization'].split('Bearer ').last
secret = ENV['DEVISE_JWT_SECRET_KEY']
JWT.decode(token, secret, true, algorithm: 'HS256',
verify_jti: true)[0]['jti']
end
def revoke_token(user)
user.update_column(:jti, generate_jti)
end
def current_token
request.env['warden-jwt_auth.token']
end
def generate_jti
SecureRandom.uuid
end
end

I also overrideapp/controllers/sessions_controller.rb with customizations for HTML users. I changed it only to add:respond_to :html.

8. Add “new” view in json format

Create an empty file: app/views/devise/sessions/new.json. Devise blew up on me without it.

Testing

Use the curl command to test logins with*:

curl -i -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' --data '{"api_user": {"email":"user@email.com","password":"the password"}}' http://127.0.0.1/api/login.json

*See this comment if that doesn’t work for you.

You should see a response that includes an Authorization header with the JWT “Bearer” token. Add it to a shell variable, then access protected resources like so:

TOKEN=eyJhbGcJzdWIiOiIxIiwic2NwIjoiYXBpX3.eyJqdGkiOiI2MWEwMjUwMS03NWNiLTQ4N2EtODU3Zi04YzU0ZmJkOTU2ZWEiLCVzZXIiLCJhdWQiOm51bGwsImlhdCI6MTU2NDY5MjUxMSwiZXhwIjoxNTY0Nzc4OTExfQ.7YflMWzsmrrS7mYqUGeB2a83G3UlpbhHBwA-EmjSfsAcurl -H 'Accept: application/json' -H "Authorization: Bearer ${TOKEN}" http://127.0.0.1/events/19wF000/memberships.json

Conclusion

There is probably room for improvement in my implementation of devise-jwt. If you have suggestions, I would love to hear them.

This setup came about through too much trial and error, and hunting for bits of documentation around the web for the JWT/Devise combination with HTML logins in the same app. There wasn’t much, which motivated me to write this article. I hope someone finds it useful.

Resources

Brent Kearney

Written by

Systems integrator, software developer, future gazer.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade