A small primer on implementing JSON Web Token (JWT) authorization in a microservice that connects to an existing Ruby on Rails 6 application that's using devise for user management.

As you begin to scale your business operations, it might be easier for your engineering team to hire a front-end engineer who can code your interface using React, Vue, or any other popular front-end framework than hiring a Ruby on Rails engineer who would need to know and understand your entire monolith before adding anything complex to the codebase. So, it becomes necessary to split out your monolith into different apps (or microservices), where one developer has ownership and can solely focus on a smaller business domain. Here is one way to approach authorization using JWTs across a Ruby on Rails app and a Kemal app.

Prerequisites

  • Rails 6+ application with Ruby 2.6.6, we use suspenders by thoughtbot to set this up
  • gem devise in your Gemfile for user authentication
  • gem dotenv-rails in your Gemfile to manage environment variables
  • Kemal application on Crystal 0.34.0

Install devise-jwt

If you're already using the devise gem, then add devise-jwt to your Gemfile.

gem 'devise-jwt', '~> 0.6.0'

Then, run this in your console to install the gem:

bundle install

Be sure that you have a gem that can manage your environment variables, like dotenv-rails. In config/initializers/devise.rb, set up your secret key:

Devise.setup do |config|
  # ...
  config.jwt do |jwt|
    jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
  end
end

Configure devise-jwt

The standard login route for devise is going to be handled by /users/sign_in. Upon successful sign in, Rails will respond with a JWT in the Authorization header. We can then pass this JWT to an app outside of Rails in order to authorize the user. Continuing with the configuration:

# config/initializers/devise.rb
config.jwt do |jwt|
  jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
  jwt.expiration_time = ENV['DEVISE_JWT_EXPIRATION_TIME_SECONDS'].to_i.seconds.to_i
  jwt.dispatch_requests = [
    ['GET', %r{users/sign_in}],
    ['POST', %r{users/refresh}]
    # Uncomment if we want to refresh on every request
    # ['GET', %r{.*}]
    # ['POST', %r{.*}]
  ]
end
config.skip_session_storage = [:http_auth, :params_auth]
  • DEVISE_JWT_EXPIRATION_TIME_SECONDS is a value in seconds that states when the JWT will expire and be up for renewal. You can set this to be as short or as long as you want
  • dispatch_requests are the routes where the JWT will be issued, displayed via the Authorization header
  • Not listed on here is revocation_requests, where authentication tokens will be revoked. Our users will only be logging out via HTML on the Rails app, so this is not needed since the JWT is automatically invalidated upon sign out

Migrate user table & update user model

Here, use the JTIMatcher revocation strategy, that stores a string in the user table which is used as a seed to create the authorization token. By updating the user#jti property, you can invalidate current tokens. Add the following migration:

# db/migrate/20200510131313_add_jti_to_users
class AddJtiMatcher < ActiveRecord::Migration[6.0]
  def change
    if User.count == 0
      add_column :users, :jti, :string, null: false
	  add_index :users, :jti, unique: true
	else
      add_column :users, :jti, :string
      User.all.each { |user| user.update_column(:jti, SecureRandom.uuid) }
      change_column_null :users, :jti, false
      add_index :users, :jti, unique: true
    end
  end
end

Then run your migration:

bundle exec rails db:migrate

Add the following to your user model, so you can manipulate the jwt_payload if need be before dispatching it:

# app/models/user.rb
class User < ApplicationRecord
  # ...
  def jwt_payload
    super
  end

  # You can log the dispatch here if need be
  def on_jwt_dispatch(_token, _payload) end
end

Add JWT helper method to controllers

You can add a concern that will be shared across your controllers.

module JwtConcern
  extend ActiveSupport::Concern

  included do
    helper_method :set_jwt
  end

  def set_jwt
    token = request.env[Warden::JWTAuth::Hooks::PREPARED_TOKEN_ENV_KEY]
    cookies[:jwt] = {
      value: token,
      expires: expiration_time.from_now,
      domain: :all,
      httponly: true
    }
  end

  def remove_jwt
    cookies.delete(:jwt, domain: :all)
  end

  private

  def expiration_time
    ENV["DEVISE_JWT_EXPIRATION_TIME_SECONDS"].to_i.seconds
  end
end
  • set_jwt method fetches the issued JWT from the Authorization header and sets it as an http-only cookie on the client's browser, applicable to subdomains with domain: :all
  • remove_jwt removes the JWT that is stored in the cookie. It is imperative to note that you must also specify the same domain that you did in set_jwt for the JWT to be removed correctly

In ApplicationController, you can use the JWT concern like so:

class ApplicationController < ActionController::Base
  include JwtConcern

  protected

  def after_sign_in_path_for(resource)
    set_jwt

    # Redirect to some authenticated path like a user dashboard
  end

  def after_sign_out_path_for(resource_or_scope)
    remove_jwt

    # Redirect to some unauthenticated path like a login page
  end
end

Using the JWT in a Kemal app

Now it's time for you to use the JWT that's stored in an http-only cookie on the client's browser, in your microservice! You can use the same strategy outlined below in any other language. You first need to ensure that the DEVISE_JWT_SECRET_KEY environment variable is also available in your microservice so that it can decrypt the JWT and fetch user information.

Ensure the following is in your shard.yml:

dependencies:
  jwt:
    github: crystal-community/jwt

Then, install the shard:

shards install

A TokenDecoder class can be defined that takes an encrypted JWT and exposes a method to check if the JWT is expired or unexpired:

require "jwt"

class TokenDecoder
  def initialize(token : String, key : String)
    @token = token
    @key = key
	@exp = 0_i64
  end
  
  def decode
    result = JWT.decode(@token, @key, JWT::Algorithm::HS256)
	payload = result[0]
	@exp = payload["exp"].as_i64
  end

  def expired?
    @exp < Time.utc.to_unix
  end

  def unexpired?
    !expired?
  end
end

Then, an auth middleware can check if the JWT present and unexpired on every request to the Kemal app:

require "./token_decoder"

class AuthHandler < Kemal::Handler
  def call(ctx)
    needs_refresh = true
    if ctx.request.cookies.has_key?("jwt") && !ctx.request.cookies["jwt"].value.blank?
      encoded_token = ctx.request.cookies["jwt"].value
      token = TokenDecoder.new(encoded_token, key: JWT_SECRET_KEY)

      begin
        token.decode
        needs_refresh = token.expired?
      rescue e : JWT::ExpiredSignatureError
        needs_refresh = true
      rescue e
        needs_refresh = true
      end
    end

    if needs_refresh
      ctx.redirect "#{ENV["RAILS_URL"]}/users/refresh"
    else
      call_next ctx
    end
  end
end

add_handler AuthHandler.new
  • Notice that if the token requires a refresh, it will redirect the browser to the Rails app for the JWT to be reissued, then the Rails app can redirect back to the SPA served by Kemal. Since the user has a session on the Rails app, this process should be relatively seamless
  • If the user does not have a session, then the Rails app would prompt the user to log in

The users#refresh controller action simply looks like this:

class Users::SessionsController < Devise::SessionsController
  include JwtConcern
  before_action :authenticate_user!, only: [:refresh]
  
  def refresh
    self.resource = warden.authenticate!(auth_options)
    sign_in(resource_name, current_user)
    set_jwt
    redirect_to ENV["MICROSERVICE_URL"]
  end
end
  • First, warden attempts to authenticate by checking the session. Then, the user is signed in and issued a JWT
  • The JWT is set in the browser as an http-only cookie and the user is redirected back to the microservice

Conclusion

You now have a very simple way of dispatching JWTs from your Rails app for use in microservices that will interface with the Rails API.

Resources