Skip to content

Instantly share code, notes, and snippets.

@jesster2k10
Last active September 18, 2024 00:56
Show Gist options
  • Save jesster2k10/e626ee61d678350a21a9d3e81da2493e to your computer and use it in GitHub Desktop.
Save jesster2k10/e626ee61d678350a21a9d3e81da2493e to your computer and use it in GitHub Desktop.
JWT Auth + Refresh Tokens in Rails

JWT Auth + Refresh Tokens in Rails

This is just some code I recently used in my development application in order to add token-based authentication for my api-only rails app. The api-client was to be consumed by a mobile application, so I needed an authentication solution that would keep the user logged in indefinetly and the only way to do this was either using refresh tokens or sliding sessions.

I also needed a way to both blacklist and whitelist tokens based on a unique identifier (jti)

Before trying it out DIY, I considered using:

  • devise-jwt which unfortunately does not support refresh tokens
  • devise_token_auth I ran into issues when it came to the changing headers on request on mobile, disabling this meant users would have to sign in periodically
  • doorkeeper This was pretty close to what I needed, however, it was quite complicated and I considered it wasn't worth the extra effort of implmeneting OAuth2 (for now)
  • api_guard This was great, almost everything I needed but it didn't play too nicely with GraphQL and I needed to implement token whitelisting also.

So, since I couldn't find any widely-used gem to meet my needs; I decided to just go DIY, and the end result works pretty well. And overview of how things works is so:

  • You call on the Jwt::Issuer module to create an access_token and refresh_token pair.
  • You call on the Jwt::Authenticator module to authenticate the access_token get the current_user and the decoeded_token
  • You call on the Jwt::Revoker module to revoke (blacklist/remove whitelist) a token
  • You call on the Jwt::Refresher module to refresh an access_token based on a refresh_token

There are more modules, but you can preview them for yourself.

There are some prequistes you need in order to use this code:

  1. You need to create a blacklisted tokens table like so: rails g model BlacklistedToken jti:string:uniq:index user:belongs_to exp:datetime

  2. If you want to use whitelisting to, create a tokens table like so: rails g model WhitelistedToken jti:string:uniq:index user:belongs_to exp:datetime

  3. Create a refresh tokens table like & model so: rails g model RefreshToken crypted_token:string:uniq user:belongs_to

class RefreshToken < ApplicationRecord
  belongs_to :user
  before_create :set_crypted_token

  attr_accessor :token

  def self.find_by_token(token)
    crypted_token = Digest::SHA256.hexdigest token
    RefreshToken.find_by(crypted_token: crypted_token)
  end

  private

  def set_crypted_token
    self.token = SecureRandom.hex
    self.crypted_token = Digest::SHA256.hexdigest(token)
  end
end
  1. Update the user model to include the associations
  has_many :refresh_tokens, dependent: :delete_all
  has_many :whitelisted_tokens, dependent: :delete_all
  has_many :blacklisted_tokens, dependent: :delete_all

Then you are pretty much ready to go!

In the future, I might make this into a gem or add redis support or similar.

I hope this gist helps someone!

class ApplicationControler < ActionController::Base
before_action :authenticate
private
def authenticate
current_user, decoded_token = Jwt::Authenticator.call(
headers: request.headers,
access_token: params[:access_token] # authenticate from header OR params
)
@current_user = current_user
@decoded_token = decoded_token
end
end
module Jwt
module Authenticator
module_function
def call(headers:, access_token:)
token = access_token || Jwt::Authenticator.authenticate_header(
headers
)
raise Errors::Jwt::MissingToken unless token.present?
decoded_token = Jwt::Decoder.decode!(token)
user = Jwt::Authenticator.authenticate_user_from_token(decoded_token)
raise Errors::Unauthorized unless user.present?
[user, decoded_token]
end
def authenticate_header(headers)
headers['Authorization']&.split('Bearer ')&.last
end
def authenticate_user_from_token(decoded_token)
raise Errors::Jwt::InvalidToken unless decoded_token[:jti].present? && decoded_token[:user_id].present?
user = User.find(decoded_token.fetch(:user_id))
blacklisted = Jwt::Blacklister.blacklisted?(jti: decoded_token[:jti])
whitelisted = Jwt::Whitelister.whitelisted?(jti: decoded_token[:jti])
valid_issued_at = Jwt::Authenticator.valid_issued_at?(user, decoded_token)
return user if !blacklisted && whitelisted && valid_issued_at
end
def valid_issued_at?(user, decoded_token)
!user.token_issued_at || decoded_token[:iat] >= user.token_issued_at.to_i
end
module Helpers
extend ActiveSupport::Concern
def logout!(user:, decoded_token:)
Jwt::Revoker.revoke(
decoded_token: decoded_token,
user: user
)
end
end
end
end
module Jwt
module Blacklister
module_function
def blacklist!(jti:, exp:, user:)
user.blacklisted_tokens.create!(
jti: jti,
exp: Time.at(exp)
)
end
def blacklisted?(jti:)
BlacklistedToken.exists?(jti: jti)
end
end
end
module Jwt
module Decoder
module_function
def decode!(access_token, verify: true)
decoded = JWT.decode(access_token, Jwt::Secret.secret, verify, verify_iat: true)[0]
raise Errors::Jwt::InvalidToken unless decoded.present?
decoded.symbolize_keys
end
def decode(access_token, verify: true)
decode!(access_token, verify: verify)
rescue StandardError
nil
end
end
end
module Jwt
module Encoder
module_function
def call(user)
jti = SecureRandom.hex
exp = Jwt::Encoder.token_expiry
access_token = JWT.encode(
{
user_id: user.id,
jti: jti,
iat: Jwt::Encoder.token_issued_at.to_i,
exp: exp
},
Jwt::Secret.secret
)
[access_token, jti, exp]
end
def token_expiry
(Jwt::Encoder.token_issued_at + Jwt::Expiry.expiry).to_i
end
def token_issued_at
Time.now
end
end
end
module Jwt
module Expiry
module_function
def expiry
2.hours
end
end
end
module Jwt
module Issuer
module_function
def call(user)
access_token, jti, exp = Jwt::Encoder.call(user)
refresh_token = user.refresh_tokens.create!
Jwt::Whitelister.whitelist!(
jti: jti,
exp: exp,
user: user
)
[access_token, refresh_token]
end
end
end
module Jwt
module Refresher
module_function
def refresh!(refresh_token:, decoded_token:, user:)
raise Errors::Jwt::MissingToken, token: 'refresh' unless refresh_token.present? || decoded_token.nil?
existing_refresh_token = user.refresh_tokens.find_by_token(
refresh_token
)
raise Errors::Jwt::InvalidToken, token: 'refresh' unless existing_refresh_token.present?
jti = decoded_token.fetch(:jti)
new_access_token, new_refresh_token = Jwt::Issuer.call(user)
existing_refresh_token.destroy!
Jwt::Blacklister.blacklist!(jti: jti, exp: decoded_token.fetch(:exp), user: user)
Jwt::Whitelister.remove_whitelist!(jti: jti)
[new_access_token, new_refresh_token]
end
end
end
module Jwt
module Revoker
module_function
def revoke(decoded_token:, user:)
jti = decoded_token.fetch(:jti)
exp = decoded_token.fetch(:exp)
Jwt::Whitelister.remove_whitelist!(jti: jti)
Jwt::Blacklister.blacklist!(
jti: jti,
exp: exp,
user: user
)
rescue StandardError
raise Errors::Jwt::InvalidToken
end
end
end
module Jwt
module Secret
module_function
def secret
Rails.application.secrets.secret_key_base
end
end
end
module Jwt
module Whitelister
module_function
def whitelist!(jti:, exp:, user:)
user.whitelisted_tokens.create!(
jti: jti,
exp: Time.at(exp)
)
end
def remove_whitelist!(jti:)
whitelist = WhitelistedToken.find_by(
jti: jti
)
whitelist.destroy if whitelist.present?
end
def whitelisted?(jti:)
WhitelistedToken.exists?(jti: jti)
end
end
end
@bipashant
Copy link

Thanks for amazing work.
I'm getting #<NameError: uninitialized constant Jwt::Authenticator::Errors, how should I define these classes?

@jesster2k10
Copy link
Author

Thanks for amazing work.
I'm getting #<NameError: uninitialized constant Jwt::Authenticator::Errors, how should I define these classes?

Hi! So I actually created a superclass of StandardError to handle application-level errors (NotFoundError, ValidationError) etc, inside my lib/errors folder.

So unless you create something similar– you will get that error.
What you can do instead is just raise a StandardError instead of Errors::*, and pass a custom message.
Or you can do what I did– use this tutorial for guidance https://www.honeybadger.io/blog/ruby-custom-exceptions/

@flexoid
Copy link

flexoid commented Apr 20, 2021

Hi @jesster2k10, thanks for sharing!
Can you please specify the license of this piece of code? I would like to partially copy-paste it, but not sure if I legally can do it. This can be helpful: https://choosealicense.com/

@jesster2k10
Copy link
Author

Hi @jesster2k10, thanks for sharing!
Can you please specify the license of this piece of code? I would like to partially copy-paste it, but not sure if I legally can do it. This can be helpful: https://choosealicense.com/

Hey! You're free to do as you please. The license would fall under MIT. Go for it!

@oadamczyk
Copy link

Hey there @jesster2k10, great work! It looks like a really readable, testable piece of code :D
I have one question: do you blacklist access_token intentionally? From my perspective, it seems incorrect to store JWT access_token in the database and on each authentication query on DB (checking if the token is blacklisted or not). It seems to me like we are losing the biggest advantage of JWT itself. Could you elaborate a bit on the idea of blacklisting refresh_token (ofc with keeping TTL of access token relatively short)? It would prevent future refreshing tokens, ofc downside is that previous access_token can be still used for couple minutes.

@jesster2k10
Copy link
Author

jesster2k10 commented May 1, 2021 via email

@oadamczyk
Copy link

oadamczyk commented May 1, 2021

Totally right, seems like it depends on the use case - as it always is ^^ Thanks for the answer and keep up the good work!

@yashgadodia
Copy link

yashgadodia commented May 12, 2021

Bless your soul my friend. Exactly what I need for my project.

@ulgut
Copy link

ulgut commented Jul 29, 2021

hi @jesster2k10, would you be able to provide the relevant portions of the user model as well?
like this :token_issued_at field?
Is it simply overwritten everytime a new RefreshToken is created?

@daniel-gato
Copy link

+1

@a-m-zill
Copy link

a-m-zill commented Jan 3, 2022

+1

@santiagorodriguezbermudez

Hey @jesster2k10! thank you so much for this! I have an ignorant question though. When checking the code, I've seen that the Issuer model creates a refresh token which (among other keys) saves the 'crypted_token' information. However, the 'token' information, which is the original hash key of crypted_token is not stored. Thus, when I sent the first information to the client I sent the refresh token without the original token information. This results in an invalid token key as the method find_by_token of the refreshed model uses the original token, hash it and compare it to the crypto token.

I dunno If I'm missing something here. Please let me know your thoughts.

@franee
Copy link

franee commented Feb 9, 2022

+1 The refresh_token needs an expiry imo, otherwise it can be abused.

@SimonVillage
Copy link

@jesster2k10 works good, can we expect a gem for this? Would be a nice open source project maybe other could contribute to

Copy link

ghost commented Aug 18, 2022

@jesster2k10 I have also deployed successfully but it not have refresh token https://github.com/markcror/sample_app/blob/master/app/controllers/api/api_controller.rb, this is frontend nextjs for it https://github.com/markcror/sample_app_nextjs, and reference on nodejs https://github.com/markcror/sample_app_nodejs, Looking forward to your complete solution, do you think it's possible

Copy link

ghost commented Sep 22, 2022

https://github.com/nickeryno/rails-boilerplate recently after the support from my colleague i got the implementation with refresh token it can work hope to get your and everyone's contribution

@maearon
Copy link

maearon commented Nov 10, 2022

https://github.com/maearon/maearon a e-commerce platform api for ruby on rails

@santanaluiz
Copy link

I appreciate the content! very helpful!

@syafilm
Copy link

syafilm commented Oct 24, 2023

This is such a hidden gem, thanks for this

@jwaiswa7
Copy link

I kept the devise-jwt gem and updated the user model, and created a refresh_tokens controller.
This gave me good guidance though. See my solution here.
https://gist.github.com/jwaiswa7/2b58535c33fe15bed3e025708ca1e56c

@rostgoat
Copy link

@jesster2k10

class Api::V1::AuthController < Api::V1::ApplicationController
  skip_before_action :authenticate_request, only: [:refresh_access_token]

  def refresh_access_token
    user = User.find_by(id: params[:id])
    refresh_token = user.refresh_tokens.last
    access_token = Jwt::Authenticator.authenticate_header(
      request.headers
    )

    begin
      # The `verify: false` option is used to skip the verification of the token's signature.
      # This is not good since if someone has an expired token, they can refresh it without verifying the signature.
      # The thing is that the access token is expired at this point and the code expects it to be expired here. The
      # issue is that JWT throws a JWT::ExpiredSignature error and haults the entire operation if `verify: true`.
      # I still think that I need to revisit this area of the code to see if there is a way to hand this solution.
      decoded_access_token = Jwt::Decoder.decode!(access_token, verify: false)

      new_access_token = Jwt::Refresher.refresh!(
        refresh_token: refresh_token,
        decoded_token: decoded_access_token,
        user: user,
      )

      render json: { access_token: new_access_token }, status: :ok
    rescue JWT::DecodeError => e
      render json: { error: "Invalid token: #{e.message}" }, status: :unauthorized
    rescue StandardError => e
      render json: { error: e.message }, status: :unauthorized
    end
  end
end

Thanks for this gist! I am using it for nextjs/rails app.

One area that stumped me was refreshing the access token using the Refresher module. Above, I kept running into JWT::SignatureExpired error whenever I tried decoding an expired access token. The only way to refresh the token was to set verify to false while decoding it, otherwise the error wouldn't let me continue.

Did you face this issue? If so, did you come up with a solution?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment