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:
- 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 apermissions
claim that contains the scoperead: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:
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:
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:
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.