Skip to content

Instantly share code, notes, and snippets.

@InsilicoSoft
Created August 3, 2017 07:55
Show Gist options
  • Select an option

  • Save InsilicoSoft/e408db6d8f5eab6ab22ad059b5c3fdcf to your computer and use it in GitHub Desktop.

Select an option

Save InsilicoSoft/e408db6d8f5eab6ab22ad059b5c3fdcf to your computer and use it in GitHub Desktop.
Custom Jwt Token encoder/decoder that takes care of confirmations and resets too.
require 'base64'
##
# A class for encoding and decoding auth information in form of JWT token. Note that only sensitive auth information
# such as user id and/or email should be encrypted in token, not any possible api payload
# @example Encrypt user's id and email
# JwtCrypto.encode({ user: { id: 1, email: '[email protected]' }}) # => returns JWT token
# @example Decode payload from provided JWT token
# JwtCrypto.decode('hhjkasdhkja.hdjsakdhak.hasjdkhasjkd') # => returns decoded payload or fails with DecodeError
class JwtCrypto
# This error should be raised when there are troubles with decoding the JWT token. The error should be handled
# at ApplicationController or whichever root controller there is.
class DecodeError < StandardError; end
class << self
# Encodes payload with provided api secret. Please specify the secret in +config/secrets.yml+
#
# @param [Hash] payload
# @param [DateTime] exp token expiry date
# @return [String] the encoded payload
def encode(payload, exp = nil)
exp ||= 5.days.from_now
JWT.encode(payload.merge(exp: exp.to_i), Rails.application.secrets.api_secret)
end
# Encodes +payload+, SHA256-encrypting the values in it or only the values provided by +keys+
#
# @param [Hash] payload
# @param [DateTime] exp token expiry date
# @param [Array<String|Symbol>] keys which corresponding values to encrypt. Default - all values.
# @return [String] encoded payload, @see ::encode
def encode_encrypting_values(payload, exp = nil, keys = nil)
keys ||= payload.keys
hash_with_encrypted_values = payload.select { |k, _| keys.include?(k) }.map do |k, v|
[k, Digest::SHA256.hexdigest(v)]
end.to_h
encode(hash_with_encrypted_values, exp)
end
# Decodes JWT token and returns the decoded data. We return data in form of hash with indifferent access to not
# worry about providing symbol or string keys for it.
#
# @param [String] token - JWT token
# @return [ActiveSupport::HashWithIndifferentAccess] decoded payload body
# @raise [DecodeError] if any other error is encountered - such as absence of Rails application secret, wrong
# or expired token, decoded data that doesn't have Hash format, etc.
def decode(token)
body = JWT.decode(token, Rails.application.secrets.api_secret)[0]
ActiveSupport::HashWithIndifferentAccess.new body.except('exp')
rescue StandardError => e
Rails.logger.fatal "JwtCrypto error: #{e}"
raise DecodeError
end
# Decodes JWT +token+ and compares SHA256 hashes of +compared_payload+'s values with retrieved values
# (which are supposed to already be encrypted)
#
# @param [String] token - JWT token
# @param [Hash] compared_payload - payload with unencrypted values that need to be compared. e.g. { key: '2345678' }
# @param [Boolean] soft - if set to false, a +DecodeError+ will be raised. Otherwise, +nil+ is returned in event
# of error - if token cannot be decoded or encrypted values do not match.
# @return [Hash|NilClass] decoded payload or +nil+ if an error occurred.
def compare_value_digests!(token, compared_payload, soft = true)
actual_payload = decode(token)
equal = compared_payload.all? { |k, v| compare_with_digest(actual_payload[k], v) }
raise DecodeError unless equal
actual_payload
rescue StandardError => e
Rails.logger.fatal "JwtCrypto error: #{e}"
raise DecodeError unless soft
nil
end
# Compares one value with digest of another
#
# @param [String] digest SHA256 digest to be compared
# @param [String] undigested string, whose SHA256 digest will be compared
# @return [Boolean] result of comparison
def compare_with_digest(digest, undigested)
digest == Digest::SHA256.hexdigest(undigested)
end
# Decodes JWT token without raising an error - instead, returns +nil+ if decoding is impossible @see +decode+
# @return [ActiveSupport::HashWithIndifferentAccess | NilClass] decoded payload body or +nil+
def soft_decode(token)
decode(token)
rescue DecodeError
nil
end
# :method confirmation_token_for / reset_password_token_for
# Generates confirmation / reset password token with encrypted user's id and email
#
# @param [User] user
# @return [String] token
def confirmation_token_for(user)
encode(user: { id: user.id, unconfirmed_email: user.unconfirmed_email }, type: :confirmation)
end
def reset_password_token_for(user)
encode(user: { id: user.id }, type: :reset_password)
end
# :method decoded_user_from_confirmation / decoded_user_from_reset_password
# Decodes confirmation /reset password token and returns user id and email in hash
#
# @param [String] token - JWT token
# @return [Hash] decoded user:
# * :id [Integer] - user id
# * :email [String] - user email
# @raise [DecodeError] if there are errors in decoding process (@see +decode+) or decoded payload hash does not
# contain type of *confirmation* or *reset_password*
def decoded_user_from_confirmation(token)
user_data = decode token
user = user_data[:user]
raise DecodeError unless user.present? && user.keys == %w(id unconfirmed_email) &&
user_data[:type]&.to_sym == :confirmation
user
end
def decoded_user_from_reset_password(token)
user_data = decode token
user = user_data[:user]
raise DecodeError unless user.present? && user.keys == %w(id) && user_data[:type]&.to_sym == :reset_password
user
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment