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

Brent Kearney
5 min readJul 31, 2019

--

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. I hope it saves you some time.

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. See below for some explanations of the configuration variables.

# 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 nilto the array, 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.

Likewise, 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 at /api/sign_[in|out]
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 user 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 override the default create method, because I have other conditions and custom responses.

# 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
private def current_token
request.env['warden-jwt_auth.token']
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. Also, if you are using port 3000 in your development environment, append :3000 to these curl commands.

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 you found it useful.

Resources

--

--