Using Cloudflare Access and devise together.

Cloudflare Access is my current go-to zero trust platform for securing internal applications that require access outside of our closed networks. Cloudflare supports effectively a reverse tunnel through their Argo offering so you don't have to open any external firewall ports. This is fine and good, but following the zero trust mantra I wanted to take things a step further and also verify the JWT provided as a cookie and in the response headers of all traffic that has passed through teams/access and been verified according to the rule set configured.

This seemed simple on the surface. A little googling pointed me to an article written by Khash over at cloud66

Everything below assumes you have CloudFlare Access working on your application up to the point of verifying the JWT provided.

The first issue I ran into was the application in question was running a older version of jwt/ruby-jwt. I kept getting the following error

JWT header not found
Nil JSON web token

Turns out jwks are not supported fully in older versions. So ensure that you have at least V2.2.3

Dependencies

gem 'httparty'
gem 'jwt' ,'~> 2.2.3'
Gemfile

I used cloud66's example devise strategy as a starting place and started digging through all the cloudflare config to extract the necessary components. In the end, you only need two things:

Gather Required Information

  1. the audience tag is located on the "overview tab of the application in Cloudflare teams.

2. The Cloudflare Access domain. This will be something like https://acmeinc.cloudflareaccess.com

Configure Secrets

You will need to add both elements listed above ether to your rails secrets or into environment variables.

production:
  CF_JWT_AUD: ACME_SUPER_SECRET_AUD_TAG
  CF_TEAMS_URL: "https://acmeinc.cloudflareaccess.com"

Devise Strategy

I had trouble getting the few examples I found online to work, so I ended up adopting a combination of the cloud66 example mashed with the ruby-jwt's examples to get a fully functional devise strategy. A few key modifications to the Cloud66 example are to imply the public key from cloudflares public facing trusted site and to also use that same URL to verify the ISS field in the JWT. The URL is provided by the token, and also from the servers configuration, and this way ensures that both are the same. The second change was to move the lambda outside of the JWT decode for readability.

# frozen_string_literal: true

## This class extends devise to validate the JWT provided by cloudflare and ensure the email addresses match
class CfAccessAuthenticatable < ::Devise::Strategies::Authenticatable
  def authenticate!
    token = request.cookies['CF_Authorization']

    unless token.present?
      Rails.logger.info('JWT Cookie not found')
      redirect!(cf_teams_url)
    end

    jwk_loader = lambda do |options|
      @cached_keys = nil if options[:invalidate]
      @cached_keys ||= keys
    end

    payload, = JWT.decode(token, nil, true, {
                            nbf_leeway: 30, # allowed drift in seconds
                            exp_leeway: 30, # allowed drift in seconds
                            iss: cf_teams_url,
                            verify_iss: true,
                            aud: cf_aud,
                            verify_aud: true,
                            verify_iat: true,
                            algorithm: 'RS256',
                            jwks: jwk_loader
                          })
    email = payload['email']

    resource = User.find_by(email: email)
    unless resource
      Rails.logger.info("User #{email} not found")
      redirect!(cf_teams_url)
    end

    remember_me(resource)
    resource.after_database_authentication
    success!(resource)
  rescue JWT::DecodeError => e
    Rails.logger.error(e.message)
    redirect!(cf_teams_url)
  end

  private

  def cf_aud
    Rails.application.secrets.CF_JWT_AUD || ENV['CF_JWT_AUD']
  end

  def cf_teams_url
    Rails.application.secrets.CF_TEAMS_URL || ENV['CF_TEAMS_URL']
  end

  def keys
    @keys ||= HTTParty.get("#{cf_teams_url}/cdn-cgi/access/certs").deep_symbolize_keys!
  end
end
app/concerns/cf_access_authenticatable.rb

Next you will need to modify the devise initializer to include the new strategy.

At the top of devise.rb

Warden::Strategies.add(:cf_jwt, Devise::Strategies::CfAccessAuthenticatable)

then within the setup block

  config.warden do |manager|
    manager.default_strategies(:scope => :user).unshift :cf_jwt
  end

In the end your file should look something like this:

Warden::Strategies.add(:cf_jwt, Devise::Strategies::CfAccessAuthenticatable)

Devise.setup do |config|
  # The secret key used by Devise. Devise uses this key to generate
  # random tokens. Changing this key will render invalid all existing
  # confirmation, reset password and unlock tokens in the database.
  # Devise will use the `secret_key_base` as its `secret_key`
  # by default. You can change it below and use your own secret key.
  config.secret_key = ENV['secret_key_base'] 
  ---
  
  config.warden do |manager|
    manager.default_strategies(:scope => :user).unshift :cf_jwt
  end
end
config/initializers/devise.rb

In closing, you should have your standard devise login, along with jwt validation of the users true ( as it can be ) origin. Remaining things to do is to patch X forwarded IP to correctly reflect the other end of the cloudflare tunnel. Stay tuned.