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 yourGemfile
for user authenticationgem dotenv-rails
in yourGemfile
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 wantdispatch_requests
are the routes where the JWT will be issued, displayed via theAuthorization
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 theAuthorization
header and sets it as an http-only cookie on the client's browser, applicable to subdomains withdomain: :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 inset_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.