Discover and enable the integrations you need to solve identityAuth0 Marketplace
Rails

How to Add Authorization to a Rails API Following TDD

Learn how to secure a Rails API with Auth0 by letting you drive by tests.

November 09, 2021

Rails

How to Add Authorization to a Rails API Following TDD

Learn how to secure a Rails API with Auth0 by letting you drive by tests.

November 09, 2021

When you're adding authorization to an application, there are two crucial questions:

  • Are users that shouldn't have access actually out?
  • Are users that should have access actually in?

If you can't answer with certainty both questions, how can you claim to have a secure application? This is something that you can test manually, but a better alternative is test automation. Concretely, I think the best methodology to get there is using Test-Driven Development (TDD).

This piece is about adding authorization to a Ruby on Rails API by following TDD. You can check out this article to learn in detail how to secure a Rails API with Auth0. Also, you can follow this article along with this repository.

The TDD Cycle

In its essence, TDD is about a loop with the three steps shown in the following picture:

TDD loop

[Source: Thoughtworks]

  • First, you make a test for a new feature. Initially, the test will fail.
  • Then, you write the minimum amount of code that makes the test pass.
  • Lastly, you refactor the code to make the implementation more solid.

Simple, isn't it? It creates a feedback loop where you write code incrementally to fulfill the task at hand. Moreover, it ensures that you build testability, i.e., you write your code so that it can be tested.

To show how to use TDD, let's add authorization to our application step by step, starting with tests. I will use OAuth to authorize requests to the API via Auth0. As I explained in my previous article, you can sign up for a free Auth0 account. Check it out to get all the details about setting up Auth0.

The Starting Application

Our journey starts with an API bootstrapped with Rails 6. It has three routes that need different levels of protection:

  • /api/messages/public: Public route.
  • /api/messages/protected: Requires a valid access token.
  • /api/messages/admin: Requires a valid access token. Since Auth0 uses JWT as its access token format, we can inspect it and make sure it has a permissions claim that contains the scope read:admin-messages.

This branch is the correct starting point. These are the baseline tests for the three routes:

# spec/api/messages_controller_spec.rb
require 'rails_helper'

describe Api::MessagesController, type: :controller do
  describe '#public' do
    subject { get :public, params: { format: :json } }

    it 'returns an accepted answer for the public endpoint' do
      subject

      expect(response).to be_ok

      message = 'The API doesn\'t require an access token to share this message.'
      expect(json_response!).to include('message' => message)
    end
  end

  describe '#protected' do
    subject { get :protected, params: { format: :json } }

    it 'returns an accepted answer for the protected endpoint' do
      subject

      expect(response).to be_ok

      message = 'The API successfully validated your access token.'
      expect(json_response!).to include('message' => message)
    end
  end

  describe '#admin' do
    subject { get :admin, params: { format: :json } }

    it 'returns an accepted answer for the admin endpoint' do
      subject

      expect(response).to be_ok

      message = 'The API successfully recognized you as an admin.'
      expect(json_response!).to include('message' => message)
    end
  end
end

We're not enforcing authorization yet. The requests work, but that'll change soon enough.

To launch the tests, move in the project's root folder and run the following command:

./go test

The go script allows you to execute different tasks, but we will use it here to run our tests.

For now, we have a pleasant list of green tests, as you can see in the following screenshot:

Green tests with unprotected endpoints

The Initial Tests

Let's start by decoding the access token in the JWT format that we get from Auth0. We're leveraging the excellent jwt gem. We don't want to test that the library works, so we're going to be relatively sparse with the testing. We want to make sure that incorrect requests fail. So, create a lib folder under the spec folder and add a file named json_web_token_spec.rb with the following content:

# spec/lib/json_web_token_spec.rb
require 'rails_helper'

require 'json_web_token'

describe JsonWebToken do
  subject { described_class }

  describe '.verify' do
    before do
      allow(Net::HTTP).to receive(:get).and_return(jwks_raw)
    end

    it 'raises exception if the token is incorrect' do
      expect { subject.verify('') }.to raise_exception(JWT::DecodeError)
    end

    it 'raises exception if the token is expired' do
      expect { subject.verify(token) }.to raise_exception(JWT::DecodeError)
    end
  end
end

To integrate it in our application, we have to wrap the jwt library with a little bit of code. For this purpose, add a file named json_web_token.rb to the lib folder in the root of the project. Put the following code in that file:

# lib/json_web_token.rb
require 'jwt'
require 'net/http'

class JsonWebToken
  class << self
    def verify(token)
      JWT.decode(token, nil,
                 true, # Verify the signature of this token
                 algorithm: algorithm,
                 iss: issuer,
                 verify_iss: true) do |header|
        key(header)
      end
    end

    def algorithm
      'RS256'
    end

    def key(header)
      jwks_hash[header['kid']]
    end

    def jwks_hash
      jwks_raw = Net::HTTP.get URI("#{issuer}.well-known/jwks.json")
      jwks_keys = Array(JSON.parse(jwks_raw)['keys'])
      jwks_keys.map do |k|
        [
          k['kid'],
          OpenSSL::X509::Certificate.new(Base64.decode64(k['x5c'].first)).public_key
        ]
      end.to_h
    end

    def issuer
      "https://#{Rails.application.config.x.auth0.issuerUri}/"
    end
  end
end

We're calling the library with the token that we want to decode. The decode method expects to receive the right key as the argument of the block. The key method calls the jwks_hash helper, which in turn fetches the list of keys from the .well-known URL. To find the right key easier, we convert the list to a hash using the id (kid) as an identifier.

The tricky part here is that we need to pass actual tokens, otherwise the library will reject them outright.

Testing Authorization

Let's start by building our tests to verify that the protected endpoint works as expected.

Using test tokens

I have set up a bunch of tokens to use in our tests. They live in the fixtures folder. They specify different conditions like being expired or using the wrong audience. This makes the tests a bit more resilient than using straight mocks.

I'm defining a helper to include one of these tokens in the request. So, add a token_helper.rb file to the spec/support folder with the following content:

# spec/support/token_helper.rb
module TokenHelper
  def read_token(name)
    path = File.expand_path(File.join(File.dirname(__FILE__), '..', 'fixtures', name))
    @token ||= File.read(path)
  end

  def authorize!(name)
    request.headers['Authorization'] = "Bearer #{read_token(name)}"
  end
end

RSpec.configure do |c|
  c.include TokenHelper
end

Now, I attach the right token to the protected requests:

# spec/api/messages_controller_spec.rb
require 'rails_helper'

describe Api::MessagesController, type: :controller do
     # ...existing code...
  
     subject { get :protected, params: { format: :json } }

     it 'returns an accepted answer for the protected endpoint' do
       # πŸ‘‡ new code
       authorize! 'validToken'
       # πŸ‘† new code

       subject

       expect(response).to be_ok
     end

     # ...existing code...

     subject { get :admin, params: { format: :json } }

     it 'returns an accepted answer for the admin endpoint' do
       # πŸ‘‡ new code
       authorize! 'validWithPermissionsToken'
       # πŸ‘† new code

       subject

       expect(response).to be_ok
     end
  
     # ...existing code...
end

These tests will prevent regressions when we enforce authorization. Let's add some tests to define the expectations that we want to enforce in the protected route:

# spec/api/messages_controller_spec.rb
require 'rails_helper'

describe Api::MessagesController, type: :controller do
  # ...existing code...
  
  describe '#protected' do
    it 'returns error for the protected endpoint if there is no token' do
      can_authenticate

      subject

      expect(response).to be_unauthorized
    end

    it 'returns error for the protected endpoint if the token is expired' do
      authorize! 'expiredToken'

      subject

      expect(response).to be_unauthorized
    end

    it 'returns error for the protected endpoint if the token has the wrong issuer' do
      authorize! 'wrongIssuerToken'

      subject

      expect(response).to be_unauthorized
    end
  end
  
  # ...existing code...
end

For reasons of space, I'm grouping them, but to remain closer to the spirit of TDD, you should add them one by one. See how they fail:

Failing Tests

Implementing authorization

To implement the authorization, we're using a before_action callback. We pick the token out of the header and verify it with our library. Traditionally, this type of helper method goes in the ApplicationController, which is the base class for all of our controllers. Here is the code:

# app/controllers/application_controller.rb
require 'json_web_token'

class ApplicationController < ActionController::API
  def authorize!
    valid, result = verify(raw_token(request.headers))

    head :unauthorized unless valid
    @token ||= result
  end

  private

  def verify(token)
    payload, = JsonWebToken.verify(token)
    [true, payload]
  rescue JWT::DecodeError => e
    [false, e]
  end

  def raw_token(headers)
    return headers['Authorization'].split.last if headers['Authorization'].present?

    nil
  end
end

And we ensure it's used only for the routes we want:

# app/controllers/api/messages_controller.rb
module Api
  class MessagesController < ApplicationController
    before_action :authorize!, except: %i[public]
  end
end

Now we see the tests passing:

Passing Tests

Custom messages

Next, we want to get a more detailed error message in the payload of the response. We need some extra assertions in the tests we wrote:

# spec/api/messages_controller_spec.rb
describe Api::MessagesController, type: :controller do
       subject

       expect(response).to be_unauthorized

       # πŸ‘‡ new code
       message = 'Nil JSON web token'
       expect(json_response!).to include('message' => message)
       # πŸ‘† new code
     end

     it 'returns error for the protected endpoint if the token is expired' do
       subject

       expect(response).to be_unauthorized
       # πŸ‘‡ new code
       expect(json_response!['message']).to include('Signature has expired')
       # πŸ‘† new code
     end

     it 'returns error for the protected endpoint if the token has the wrong issuer' do
       subject

       expect(response).to be_unauthorized
       # πŸ‘‡ new code
       expect(json_response!['message']).to include('Invalid issuer')
       # πŸ‘† new code
     end

     it 'returns error for the protected endpoint if the token has the wrong audience' do

To implement this capability, we need to pass the message in the response whenever an error happens.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
   def authorize!
     valid, result = verify(raw_token(request.headers))

     # πŸ‘‡ old code
     # head :unauthorized unless valid
     # πŸ‘‡ new code
     render json: { message: result }.to_json, status: :unauthorized unless valid

     @token ||= result
   end

Validating the audience

Our code has a serious flaw: it doesn't validate the audience contained in the JWT. Let's fix that to make sure that the application is the intended user of the token. A new test codes the behavior:

# spec/api/messages_controller_spec.rb
require 'rails_helper'

describe Api::MessagesController, type: :controller do
  # ...existing code...
  
  describe '#protected' do
    subject { get :protected, params: { format: :json } }

    it 'returns error for the protected endpoint if the token has the wrong audience' do
      authorize! 'wrongAudienceToken'

      subject

      expect(response).to be_unauthorized
      expect(json_response!['message']).to include('Invalid audience')
    end
  end
  
  # ...existing code...
end

Conveniently, the change happens in the JWT.decode method that we rely on:

# lib/json_web_token.rb
require 'jwt'
require 'net/http'

class JsonWebToken
  class << self
    def verify(token)
      JWT.decode(token, nil,
                 true, # Verify the signature of this token
                 algorithm: algorithm,
                 iss: Rails.application.config.x.auth0.issuerUri,
                  # πŸ‘‡ new code
                  verify_iss: true,
                  aud: Rails.application.config.x.auth0.audience,
                  verify_aud: true) do |header|
                  # πŸ‘† new code
        key(header)
      end
    end

    # ...existing code...
end

Testing Permissions

At this point, we've got the protected route covered. Now, we shall focus on the admin route. The next step is checking for the correct permission claim in the token. Our regular token doesn't have the claim, so it should fail the test:

# spec/api/messages_controller_spec.rb
require 'rails_helper'

describe Api::MessagesController, type: :controller do
  # ...existing code...
  
  describe '#admin' do
    subject { get :admin, params: { format: :json } }

    it 'returns error for the admin endpoint if the token does not have permissions' do
      authorize! 'validToken'

      subject

      expect(response).to be_unauthorized
      expect(json_response!['message']).to include('Access is denied')
    end
  end
  
  # ...existing code...
end

Now, let's implement the permission check in the controller:

# app/controllers/application_controller.rb
require 'json_web_token'

class ApplicationController < ActionController::API
  # ...existing code...
  
  def can_read_admin_messages!
    check_permissions(@token, 'read:admin-messages')
  end

  def check_permissions(token, permission)
    permissions = token['permissions'] || []
    permissions = permissions.split if permissions.is_a? String

    unless permissions.include?(permission)
      render json: { message: 'Access is denied' }.to_json,
             status: :unauthorized
    end
  end
  
  # ...existing code...
end

To make sure the check is applied to the correct route, we follow a similar approach to before, based on a before_action:

# app/controllers/api/messages_controller.rb
module Api
  class MessagesController < ApplicationController
    # ...existing code...
    
    before_action :can_read_admin_messages!, only: %i[admin]
end

Now you can run the tests to make sure everything works as expected.

We Got There!

Writing code following TDD is all about having a tight feedback loop. This isn't easy to show in writing due to a large number of small changes, but I hope I have given you a good glimpse.

Using TDD brings several benefits:

  • We've confirmed that our implementation, you know, works
  • Our test suite describes the application's behavior in an executable way instead of potentially misleading documentation
  • We're incorporating testability into our code from the beginning
  • Often, we tend to build things that we might need for a future that never comes. If you encode your expectations as tests, that becomes more visible, and it helps prevent it from happening.

Perhaps not every line of code that you write needs to be covered by tests. Nevertheless, a focus on testability helps to deliver better products more reliably.

You can download the sample project shown in this article from this GitHub repository.

  • Twitter icon
  • LinkedIn icon
  • Faceboook icon