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

July 2019

Image for post
Image for post

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
  • 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

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

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

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

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?

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

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-EmjSfsA

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

Written by

Systems integrator, software developer, future gazer.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store