gregbeech/sandal

View on GitHub
lib/sandal.rb

Summary

Maintainability
B
5 hrs
Test Coverage
require "openssl"
require "zlib"
require "sandal/version"
require "sandal/claims"
require "sandal/enc"
require "sandal/json"
require "sandal/sig"
require "sandal/util"


# A library for creating and reading JSON Web Tokens (JWT), supporting JSON Web Signatures (JWS) and JSON Web Encryption
# (JWE).
#
# Currently supports draft-07 of the JWT spec, and draft-10 of the JWS and JWE specs.
module Sandal

  # The base error for all errors raised by this library.
  class Error < StandardError; end

  # The error that is raised when a key provided for signing/encryption/etc. is invalid.
  class KeyError < Error; end

  # The error that is raised when there is a problem with a token.
  class TokenError < Error; end

  # The error that is raised when a token is invalid.
  class InvalidTokenError < TokenError; end

  # The error that is raised when a claim within a token is invalid.
  class ClaimError < InvalidTokenError; end

  # The error that is raised when the token has expired.
  class ExpiredTokenError < ClaimError; end

  # The error that is raised when a token is unsupported (e.g. the algorithm used to encrypt the token is not supported 
  # by this library or by the Ruby platform it is executing on).
  class UnsupportedTokenError < TokenError; end

  # The default options for token handling.
  #
  # ignore_exp:: 
  #   Whether to ignore the expiry date of the token. This setting is just to help get things working and should always
  #   be false in real apps!
  # ignore_nbf:: 
  #   Whether to ignore the not-before date of the token. This setting is just to help get things working and should
  #   always be false in real apps!
  # ignore_signature:: 
  #   Whether to ignore the signature of signed (JWS) tokens.  This setting is just tohelp get things working and should
  #   always be false in real apps!
  # max_clock_skew:: 
  #   The maximum clock skew, in seconds, when validating times. If your server time is out of sync with the token
  #   server then this can be increased to take that into account. It probably shouldn't be more than about 300.
  # signature_policy::
  #   The policy for requiring signatures in tokens. The possible values are:
  #   - :strict (default) - The innermost token must be signed. This is the recommended policy.
  #   - :none - No signature is required. This _really_ isn't recommended.
  # valid_iss:: 
  #   A list of valid token issuers, if validation of the issuer claim is required.
  # valid_aud:: 
  #   A list of valid audiences, if validation of the audience claim is required.
  DEFAULT_OPTIONS = {
    ignore_exp: false,
    ignore_nbf: false,
    ignore_signature: false,
    max_clock_skew: 0,
    signature_policy: :strict,
    valid_iss: [],
    valid_aud: []
  }

  # Overrides the default options.
  #
  # @param defaults [Hash] The options to override (see {DEFAULT_OPTIONS} for details).
  # @return [Hash] The new default options.
  def self.default!(defaults)
    DEFAULT_OPTIONS.merge!(defaults)
  end

  # Checks whether a token is encrypted.
  #
  # @param token [String or Array] The token, or token parts.
  # @return [Boolean] true if the token is encrypted; otherwise false.
  def self.is_encrypted?(token)
    if token.is_a?(String)
      token.count(".") == 4
    else
      token.count == 5
    end
  end

  # Checks whether a token is signed.
  #
  # @param token [String or Array] The token, or token parts.
  # @return [Boolean] true if the token is signed; otherwise false.
  def self.is_signed?(token)
    if token.is_a?(String)
      !token.end_with?(".") && token.count(".") == 2
    else
      token.count == 3 && !token[2].nil? && !token[2].empty?
    end
  end

  # Creates a signed JSON Web Token.
  #
  # @param payload [String or Hash] The payload of the token. Hashes will be encoded as JSON.
  # @param signer [#name,#sign] The token signer, which may be nil for an unsigned token.
  # @param header_fields [Hash] Header fields for the token (note: do not include "alg").
  # @return [String] A signed JSON Web Token.
  def self.encode_token(payload, signer, header_fields = nil)
    signer ||= Sandal::Sig::NONE

    header = {}
    header["alg"] = signer.name
    header = header_fields.merge(header) if header_fields
    header = Sandal::Json.dump(header)

    payload = Sandal::Json.dump(payload) unless payload.is_a?(String)

    sec_input = [header, payload].map { |p| Sandal::Util.jwt_base64_encode(p) }.join(".")
    signature = signer.sign(sec_input)
    [sec_input, Sandal::Util.jwt_base64_encode(signature)].join(".")
  end

  # Creates an encrypted JSON Web Token.
  #
  # @param payload [String] The payload of the token.
  # @param encrypter [#name,#alg,#encrypt] The token encrypter.
  # @param header_fields [Hash] Header fields for the token (note: do not include "alg" or "enc").
  # @return [String] An encrypted JSON Web Token.
  def self.encrypt_token(payload, encrypter, header_fields = nil)
    header = {}
    header["enc"] = encrypter.name
    header["alg"] = encrypter.alg.name
    header = header_fields.merge(header) if header_fields

    if header.has_key?("zip")
      unless header["zip"] == "DEF"
        raise ArgumentError, "Invalid zip algorithm."
      end
      payload = Zlib::Deflate.deflate(payload, Zlib::BEST_COMPRESSION)
    end 

    encrypter.encrypt(Sandal::Json.dump(header), payload)
  end

  # Decodes and validates a signed and/or encrypted JSON Web Token, recursing into any nested tokens, and returns the 
  # payload.
  #
  # The block is called with the token header as the first parameter, and should return the appropriate signature or
  # decryption method to either validate the signature or decrypt the token as applicable. When the tokens are nested, 
  # this block will be called once per token. It can optionally have a second options parameter which can be used to
  # override the {DEFAULT_OPTIONS} on a per-token basis; options are not persisted between yields.
  #
  # @param token [String] The encoded JSON Web Token.
  # @param depth [Integer] The maximum depth of token nesting to decode to.
  # @yieldparam header [Hash] The JWT header values.
  # @yieldparam options [Hash] (Optional) A hash that can be used to override the default options.
  # @yieldreturn [#valid? or #decrypt] The signature validator if the token is signed, or the token decrypter if the
  #   token is encrypted.
  # @return [Hash or String] The payload of the token as a Hash if it was JSON, otherwise as a String.
  # @raise [Sandal::TokenError] The token is invalid or not supported.
  def self.decode_token(token, depth = 16)
    parts = token.split(".")
    decoded_parts = decode_token_parts(parts)
    header = decoded_parts[0]

    options = DEFAULT_OPTIONS.clone
    decoder = yield header, options if block_given?

    if is_encrypted?(parts)
      payload = decoder.decrypt(parts)
      if header.has_key?("zip")
        unless header["zip"] == "DEF"
          raise Sandal::InvalidTokenError, "Invalid zip algorithm."
        end
        payload = Zlib::Inflate.inflate(payload)
      end
    else
      payload = decoded_parts[1]
      unless options[:ignore_signature]
        validate_signature(parts, decoded_parts[2], decoder) 
      end
    end

    if header.has_key?("cty") && header["cty"] =~ /\AJWT\Z/i
      if depth > 0
        if block_given?
          decode_token(payload, depth - 1, &Proc.new)
        else 
          decode_token(payload, depth - 1)
        end
      else
        payload
      end
    else
      if options[:signature_policy] == :strict && !is_signed?(parts)
        raise Sandal::UnsupportedTokenError, "The innermost token is not signed."
      end
      parse_and_validate(payload, options)
    end
  end

  private

  # Decodes and validates a signed JSON Web Token.
  def self.validate_signature(parts, signature, validator)
    raise UnsupportedTokenError, "Unsupported signature method." if validator.nil?
    secured_input = parts.take(2).join(".")
    unless validator.valid?(signature, secured_input)
      raise InvalidTokenError, "Invalid signature."
    end
  end

  # Decodes the parts of a token.
  def self.decode_token_parts(parts)
    parts = parts.map { |part| Sandal::Util.jwt_base64_decode(part) }
    parts[0] = Sandal::Json.load(parts[0])
    parts
  rescue
    raise InvalidTokenError, "Invalid token encoding."
  end

  # Parses the content of a token and validates the claims if is JSON claims.
  def self.parse_and_validate(payload, options)
    claims = Sandal::Json.load(payload) rescue nil
    if claims
      claims.extend(Sandal::Claims).validate_claims(options)
    else
      payload
    end
  end

end