class Rack::CloudflareJwt::Auth

Authentication middleware

@see developers.cloudflare.com/access/setting-up-access/validate-jwt-tokens/

Constants

CERTS_PATH

Certs path

DEFAULT_ALGORITHM

Default algorithm

HEADER_NAME

CloudFlare JWT header.

PATH_INFO

Key for get current path.

TOKEN_REGEX

Token regex.

@see github.com/jwt/ruby-jwt/tree/v2.2.1#algorithms-and-usage

Attributes

policies[R]
team_domain[R]

Public Class Methods

new(app, team_domain, policies = {}) click to toggle source

Initializes middleware

@example Initialize middleware in Rails

config.middleware.use(
  Rack::CloudflareJwt::Auth,
  ENV['RACK_CLOUDFLARE_JWT_TEAM_DOMAIN'],
  '/admin'   => <cloudflare-aud-1>,
  '/manager' => <cloudflare-aud-2>,
)

@param team_domain [String] the Team Domain (e.g. 'test.cloudflareaccess.com'). @param policies [Hash<String, String>] the policies with paths and AUDs.

# File lib/rack/cloudflare_jwt/auth.rb, line 50
def initialize(app, team_domain, policies = {})
  @app         = app
  @team_domain = team_domain
  @policies    = policies

  check_policy_auds!
  check_paths_type!
end

Public Instance Methods

call(env) click to toggle source

Public: Call a middleware.

# File lib/rack/cloudflare_jwt/auth.rb, line 60
def call(env)
  if !path_matches?(env)
    @app.call(env)
  elsif missing_auth_header?(env)
    return_error('Missing Authorization header')
  elsif invalid_auth_header?(env)
    return_error('Invalid Authorization header format')
  else
    verify_token(env)
  end
end

Private Instance Methods

check_paths_type!() click to toggle source

Private: Check paths type.

# File lib/rack/cloudflare_jwt/auth.rb, line 86
def check_paths_type!
  policies.each_key do |path|
    raise ArgumentError, 'each key element must be a String' unless path.is_a?(String)
    raise ArgumentError, 'each key element must not be empty' if path.empty?
    raise ArgumentError, 'each key element must start with a /' unless path.start_with?('/')
  end
end
check_policy_auds!() click to toggle source

Private: Check policy auds.

# File lib/rack/cloudflare_jwt/auth.rb, line 75
def check_policy_auds!
  raise ArgumentError, 'policies cannot be nil/empty' if policies.values.empty?

  policies.each_value do |policy_aud|
    next unless !policy_aud.is_a?(String) || policy_aud.strip.empty?

    raise ArgumentError, 'policy AUD argument cannot be nil/empty'
  end
end
decode_token(token, secret, policy_aud) click to toggle source

Private: Decode a token.

@param token [String] the token. @param secret [String] the public key. @param policy_aud [String] the CloudFlare AUD.

@example

[
  {"data"=>"test"}, # payload
  {"alg"=>"RS256"} # header
]

@return [Array<Hash>] the token or `nil` at error. @raise [DecodeTokenError] if the token is invalid.

@see github.com/jwt/ruby-jwt/tree/v2.2.1#algorithms-and-usage

# File lib/rack/cloudflare_jwt/auth.rb, line 134
def decode_token(token, secret, policy_aud)
  Rack::JWT::Token.decode(token, secret, true, aud: policy_aud, verify_aud: true, algorithm: DEFAULT_ALGORITHM)
rescue ::JWT::VerificationError
  raise DecodeTokenError, 'Invalid JWT token : Signature Verification Error'
rescue ::JWT::ExpiredSignature
  raise DecodeTokenError, 'Invalid JWT token : Expired Signature (exp)'
rescue ::JWT::IncorrectAlgorithm
  raise DecodeTokenError, 'Invalid JWT token : Incorrect Key Algorithm'
rescue ::JWT::ImmatureSignature
  raise DecodeTokenError, 'Invalid JWT token : Immature Signature (nbf)'
rescue ::JWT::InvalidIssuerError
  raise DecodeTokenError, 'Invalid JWT token : Invalid Issuer (iss)'
rescue ::JWT::InvalidIatError
  raise DecodeTokenError, 'Invalid JWT token : Invalid Issued At (iat)'
rescue ::JWT::InvalidAudError
  raise DecodeTokenError, 'Invalid JWT token : Invalid Audience (aud)'
rescue ::JWT::InvalidSubError
  raise DecodeTokenError, 'Invalid JWT token : Invalid Subject (sub)'
rescue ::JWT::InvalidJtiError
  raise DecodeTokenError, 'Invalid JWT token : Invalid JWT ID (jti)'
rescue ::JWT::DecodeError
  raise DecodeTokenError, 'Invalid JWT token : Decode Error'
end
fetch_public_keys() click to toggle source

Private: Fetch public keys.

@return [Array<Hash>] the public keys.

# File lib/rack/cloudflare_jwt/auth.rb, line 199
def fetch_public_keys
  json = Net::HTTP.get(team_domain, CERTS_PATH)
  json.empty? ? [] : MultiJson.load(json, symbolize_keys: true).fetch(:keys)
rescue StandardError
  []
end
fetch_public_keys_cached() click to toggle source

Private: Get cached public keys.

Store a keys in the cache only 10 minutes.

@return [Array<Hash>] the public keys.

# File lib/rack/cloudflare_jwt/auth.rb, line 211
def fetch_public_keys_cached
  key = [self.class.name, '#secrets'].join('_')

  if defined? Rails
    Rails.cache.fetch(key, expires_in: 600) { fetch_public_keys }
  elsif defined? Padrino
    keys = Padrino.cache[key]
    keys || Padrino.cache.store(key, fetch_public_keys, expires: 600)
  else
    fetch_public_keys
  end
end
invalid_auth_header?(env) click to toggle source

Private: Check if auth header is invalid.

@return [Boolean] true if it is, false otherwise.

# File lib/rack/cloudflare_jwt/auth.rb, line 168
def invalid_auth_header?(env)
  env[HEADER_NAME] !~ TOKEN_REGEX
end
logger() click to toggle source

Private: Get a logger.

@return [ActiveSupport::Logger] the logger.

# File lib/rack/cloudflare_jwt/auth.rb, line 227
def logger
  if defined? Rails
    Rails.logger
  elsif defined? Padrino
    Padrino.logger
  end
end
missing_auth_header?(env) click to toggle source

Private: Check if no auth header.

@return [Boolean] true if it is, false otherwise.

# File lib/rack/cloudflare_jwt/auth.rb, line 175
def missing_auth_header?(env)
  env[HEADER_NAME].nil? || env[HEADER_NAME].strip.empty?
end
path_matches?(env) click to toggle source

Private: Check if current path is in the policies.

@return [Boolean] true if it is, false otherwise.

# File lib/rack/cloudflare_jwt/auth.rb, line 161
def path_matches?(env)
  policies.empty? || policies.keys.any? { |ex| env[PATH_INFO].start_with?(ex) }
end
public_keys() click to toggle source

Private: Get public keys.

@return [Array<OpenSSL::PKey::RSA>] the public keys.

# File lib/rack/cloudflare_jwt/auth.rb, line 190
def public_keys
  fetch_public_keys_cached.map do |jwk_data|
    ::JWT::JWK.import(jwk_data).keypair
  end
end
return_error(message) click to toggle source

Private: Return an error.

# File lib/rack/cloudflare_jwt/auth.rb, line 180
def return_error(message)
  body    = { error: message }.to_json
  headers = { 'Content-Type' => 'application/json' }

  [403, headers, [body]]
end
verify_token(env) click to toggle source

Private: Verify a token.

# File lib/rack/cloudflare_jwt/auth.rb, line 95
def verify_token(env)
  # extract the token from header.
  token         = env[HEADER_NAME]
  policy_aud    = policies.find { |path, _aud| env[PATH_INFO].start_with?(path) }&.last
  decoded_token = public_keys.find do |key|
    break decode_token(token, key.public_key, policy_aud)
  rescue DecodeTokenError => e
    logger.info e.message
    nil
  end

  if decoded_token
    logger.debug 'CloudFlare JWT token is valid'

    env['jwt.payload'] = decoded_token.first
    env['jwt.header']  = decoded_token.last
    @app.call(env)
  else
    return_error('Invalid token')
  end
end