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 both questions with certainty, 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 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. Moreover, it ensures that you build testability, meaning you write your code so that it can be tested.
To show how to use TDD, let's add authorization to your application step by step, starting with tests. I will use OAuth, a battle-tested and widely used authorization framework for web applications, to authorize requests to the API via Auth0.
In this context, Auth0 fulfills the role of the authorization server and abstracts a significant part of the work away from you. That way, you can focus on delivering value to your users.
Getting Started
We're getting started with our base application. This branch is a good starting point. You can download it by running the following command in a terminal window:
git clone -b starter --single-branch https://github.com/auth0-blog/securing-api-rails.git
The API has three endpoints with 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
.
Running the Application
To run the application, we first need the correct ruby version. The easiest way to do so is to use a version manager like rbenv. Once you install it, run this command inside the repository to install the right version of ruby:
rbenv install
Install the dependencies for the application:
bundle install
And finally, run the application:
bin/rails s
You can verify that the application is working correctly with curl
:
curl localhost:6060/api/messages/public
The command will return a 200 code plus the message:
{"message": "The API doesn't require an access token to share this message."}
Creating an API on Auth0
To secure the API with Auth0, you need an Auth0 account. If you haven't one, you can sign up for free right now. In the APIs section of the Auth0 dashboard, click Create API. Provide a name and an identifier for your API. You will use the identifier as an audience
later when configuring the access token verification. Leave the Signing Algorithm as RS256.
Once you create the API, go to the Permissions tab in the API details and add permission called read:admin-messages
.
Note: While in the Auth0 Dashboard, take note of your Auth0 domain. You will need it soon. The domain is a string in the form
YOUR-TENANT-NAME.auth0.com
whereYOUR-TENANT-NAME
is the name you provided when you created your account with Auth0. For more information, check the documentation.
Connecting the application to Auth0
All right, your application is ready to go and in dire need of some security. Before that, you need to add some configurations.
After creating the API, you dutifully stored the domain
and the audience
, right? Let's use them. The convention in the Rails world is to add this in the config
folder using YAML. The file is called config/auth0.yml
.
You don't want to store credentials in our code, so you'll export the values as environment variables, named AUTH0_DOMAIN
and AUTH0_AUDIENCE
. The configuration uses those values and keeps them safely away from source control!
development:
issuerUri: <%= ENV["AUTH0_DOMAIN"] %>
audience: <%= ENV["AUTH0_AUDIENCE"] %>
Now, set AUTH0_DOMAIN
and AUTH0_AUDIENCE
environment variables to your API's domain
and audience
values. I used the dotenv-rails
gem, but you can use what works best for you.
The Initial Tests
Let's start by decoding the access token in the JWT format you get from Auth0. You're leveraging the excellent jwt gem. You don't want to test that the library works, so you will be relatively sparse with the testing. You want to make sure that incorrect requests fail.
First, start by adding the jwt
gem to your Gemfile:
gem 'jwt'
Then make sure that it installs successfully by running in your terminal:
bundle install
If you go to spec/api/messages_controller_spec.rb
, you'll find the baseline tests for the MessagesController
routes that were described above:
# 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
You're not enforcing authorization yet. The requests work, but that'll change soon enough.
At this point, you can launch the tests. Move to the project's root folder and run the following command:
./go test
The go
script allows you to execute different tasks, but you will use it here to run our tests.
For now, you have a pleasant list of green tests, as you can see in the following screenshot:
Once the gem has been installed and you have seen the tests run, 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 }
# rubocop:disable Layout/LineLength
let(:token) do
'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik5FTXpRakpEUTBSRk4wUXlNemxETmpVME1VRTFNak00TWpsQ09UWXdNamMzTlVWQk9UUkVSZyJ9.eyJpc3MiOiJodHRwczovL2hjZXJpcy5ldS5hdXRoMC5jb20vIiwic3ViIjoiZ29vZ2xlLW9hdXRoMnwxMTE0NjA0MTk0NTcyODg5MzU3ODciLCJhdWQiOiJzaGVsZjIuaGNlcmlzLmNvbSIsImlhdCI6MTU1NTcxNzM1MywiZXhwIjoxNTU1NzI0NTUzLCJhenAiOiJxMU1Ebmhwa0VDRGJqU2RBOU1Tc2ROUmJYRUtoV0lZaiIsInNjb3BlIjoicHJvZmlsZSBjcmVhdGU6Ym9va3MifQ.HTPZ3ISGdzUYc190vq8rN8lfQKvgg47uIbxGfBmrbJfsQOEg2TQ-oMlTV3j8e486zhlu1NAHh2neIhMmgfJpxuXkMQrnxCwSb_sSHpNU7TNwNY9hnATvU3nslqz-4VW1FwOxtjF38k7uVqZ9Xusm2skH5DR6BPh3lU2T-I79OMVHfQb47vzNBfbCu6xx9cGBzdeJdu9ADHJOnhE8PRp4fpdQ8lDm3hNAMDaKrKXBS49HfxSsEswC5u6WR5FnWm7hCe4CFNBuosMohRkDSGRWGwQcVIAzaQASXMx1NsWpkBSBytlCsQkxYaVK7dV1syXeXqJSCoZKcRHpF-hL50xrOw'
end
let(:jwks_raw) do
# rubocop:disable Style/StringLiterals
"{\"keys\":[{\"alg\":\"RS256\",\"kty\":\"RSA\",\"use\":\"sig\",\"x5c\":[\"MIIDATCCAemgAwIBAgIJUehs79ahslK3MA0GCSqGSIb3DQEBCwUAMB4xHDAaBgNVBAMTE2hjZXJpcy5ldS5hdXRoMC5jb20wHhcNMTkwNDE2MTkwNzQ3WhcNMzIxMjIzMTkwNzQ3WjAeMRwwGgYDVQQDExNoY2VyaXMuZXUuYXV0aDAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAso5viNLtITh86OESO6njyqbtf+iPBEcQNmWohKEKMSDTeeWxJP15mWDUPB+EAKTakudsJ/Rs/MiTiEHOJubJ6BVMYyPd/3E9G2fj5KCbHF9140H4UyJfGk9jlYtKZGPJ1QlzxEZ1Krr4LSMO+P/PjD606wPSW6bd9dAUufmYTTJOpNQW/dw0V6meAr1fm1267f5XCJfjMkzQQmFtSpxDN/IpzJgWcjEsQU/0r+KSdzKf7viqotfK9soDuvni292dNzrLDiwMLWth9+6JVi6TMV5uJPfbJInQgOoaRowPWVquavNxXk/hrur4aBdP229jUe9wX+wk5MGV/uzGbEj59QIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBS9AsVL15G7Z9uI6p/7I7O7aHaCPDAOBgNVHQ8BAf8EBAMCAoQwDQYJKoZIhvcNAQELBQADggEBALIBpf5Aizfgw2Dge8xJyKELO6kRO0nrBFNyP0viajcRA3jwl9LuV316TjE8eIitmEM0nP4U9AeSkeEPksJBHMak4w+GuE7SkeZ5z6fjpNcZ/1nzJVZMDftjJDNbLeCXO/5bq6ySzYVl53pg5I3auLwEEDcrZKHhRjW0IHxBSqmhYZGajymAaBltHsYS8NP6TfDaT1dXw2EQwgIjxXeoGaQTieX0blGjrJ2y8IRBp1EZ9w2OdHaLEbkD08ndn1m5mQrkX/+F2cSiDZTtrm5Isw1TEJusBbM0j+kEsdwz2VijWIL5K2wjgLMm+tBd5OtibDSoeCNqBW+F/sjtBlMcTq4=\"],\"n\":\"so5viNLtITh86OESO6njyqbtf-iPBEcQNmWohKEKMSDTeeWxJP15mWDUPB-EAKTakudsJ_Rs_MiTiEHOJubJ6BVMYyPd_3E9G2fj5KCbHF9140H4UyJfGk9jlYtKZGPJ1QlzxEZ1Krr4LSMO-P_PjD606wPSW6bd9dAUufmYTTJOpNQW_dw0V6meAr1fm1267f5XCJfjMkzQQmFtSpxDN_IpzJgWcjEsQU_0r-KSdzKf7viqotfK9soDuvni292dNzrLDiwMLWth9-6JVi6TMV5uJPfbJInQgOoaRowPWVquavNxXk_hrur4aBdP229jUe9wX-wk5MGV_uzGbEj59Q\",\"e\":\"AQAB\",\"kid\":\"NEMzQjJDQ0RFN0QyMzlDNjU0MUE1MjM4MjlCOTYwMjc3NUVBOTRERg\",\"x5t\":\"NEMzQjJDQ0RFN0QyMzlDNjU0MUE1MjM4MjlCOTYwMjc3NUVBOTRERg\"}]}"
# rubocop:enable Style/StringLiterals
end
# rubocop:enable Layout/LineLength
let(:jwks_response) { Net::HTTPSuccess.new(1.0, '200', body: jwks_raw) }
describe '.verify' do
before do
allow(Net::HTTP).to receive(:get_response).and_return(jwks_response)
allow(jwks_response).to receive(:body).and_return(jwks_raw)
allow(Rails.configuration).to receive_message_chain('auth0.domain').and_return('AUTH0_DOMAIN_STUB')
allow(Rails.configuration).to receive_message_chain('auth0.audience').and_return('AUTH0_AUDIENCE_STUB')
end
it 'shows an error if the token is incorrect' do
expect(subject.verify('').error.message).to eq("Not enough or too many segments")
end
it 'shows an error if the token is expired' do
expect(subject.verify(token).error.message).to eq("Signature has expired")
end
end
end
In this test, you are using RSpec's allow
method, what allow
does is allow us to test the class Net::HTTP
for example, to receive a method called get_response
and return a Net::HTTPSuccess
.
You are also mocking the response of the .well-known
endpoint and its body and also the Rails configuration. You could load your env variables to be accessed from the test, but for the sake of this blog post, we are going to mock them.
Next, you need 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 project's root. Put the following code in that file:
# lib/json_web_token.rb
require 'jwt'
require 'net/http'
class JsonWebToken
class << self
Error = Struct.new(:message, :status)
Response = Struct.new(:decoded_token, :error)
def domain_url
"https://#{Rails.configuration.auth0.domain}/"
end
def verify(token)
jwks_uri = URI("#{domain_url}.well-known/jwks.json")
jwks_response = Net::HTTP.get_response jwks_uri
unless jwks_response.is_a? Net::HTTPSuccess
error = Error.new('Unable to verify credentials', :internal_server_error)
return Response.new(nil, error)
end
jwks_hash = JSON.parse(jwks_response.body).deep_symbolize_keys
decoded_token = JWT.decode(token, nil, true, {
algorithm: 'RS256',
iss: domain_url,
verify_iss: true,
aud: Rails.configuration.auth0.audience.to_s,
verify_aud: true,
jwks: { keys: jwks_hash[:keys] }
})
Response.new(decoded_token, nil)
rescue JWT::VerificationError, JWT::DecodeError => e
error = Error.new(e.message, :unauthorized)
Response.new(nil, error)
end
end
end
Here you are using a couple of Ruby Struct
to represent a response and error. In a nutshell, the verify
method calls the .well-known
endpoint in order to retrieve the JWKS. Then, the JWT.decode
method attempts to decode the token passed as a parameter. If everything goes well, you return the decoded token. Otherwise, you return an error.
If you want to learn more about the arguments of the JWT.decode
function and the JSON Web Key Set (JWKS) you can read more about it in the Rails Authorization Guide by Example, specifically the section "What is the Auth0Client Class doing under the hood".
Testing Authorization
Let's build our tests to verify that the protected endpoint works as expected.
Note you don't want to test actual tokens. What you want to test is that your code works. In this case, the verify
method works. It could be a bit tricky in this scenario because the verify
method is only used as a wrapper for the JWT.decode
function, so it might give you the impression you need to test the JWT.decode
method, but this is not the idea of unit testing.
Let's go ahead and add the stub in messages_controller_spec.rb
:
# 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
allow(JsonWebToken).to receive(:verify).and_return(double(decoded_token: :valid, error: nil))
# 👆 new code
subject
expect(response).to be_ok
# 👇 new code
message = 'The API successfully validated your access token.'
expect(json_response!).to include('message' => message)
# 👆 new code
end
# ...existing code...
subject { get :admin, params: { format: :json } }
it 'returns an accepted answer for the admin endpoint' do
# 👇 new code
allow(JsonWebToken).to receive(:verify).and_return(double(decoded_token: :valid, error: nil))
# 👆 new code
subject
expect(response).to be_ok
# 👇 new code
message = 'The API successfully recognized you as an admin.'
expect(json_response!).to include('message' => message)
# 👆 new code
end
# ...existing code...
end
These tests will prevent regressions when we enforce authorization.
Let's add some tests to define the expectations you 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
context 'with error' do
it 'returns an error for the protected endpoint if the token has the wrong audience' do
message = 'Invalid audience'
error_struct = double(message: message, status: :unauthorized)
response_struct = double(decoded_token: nil, error: error_struct)
allow(JsonWebToken).to receive(:verify).and_return(response_struct)
subject
expect(response).to be_unauthorized
expect(json_response!).to include('message' => message)
end
it 'returns an error for the protected endpoint if there is no token' do
message = 'Nil JSON web token'
error_struct = double(message: message, status: :unauthorized)
response_struct = double(decoded_token: nil, error: error_struct)
allow(JsonWebToken).to receive(:verify).and_return(response_struct)
subject
expect(response).to be_unauthorized
expect(json_response!).to include('message' => message)
end
it 'returns an error for the protected endpoint if the token is expired' do
error_struct = double(message: message, status: :unauthorized)
response_struct = double(decoded_token: nil, error: error_struct)
allow(JsonWebToken).to receive(:verify).and_return(response_struct)
subject
expect(response).to be_unauthorized
expect(json_response!).to include('message' => message)
end
it 'returns an error for the protected endpoint if the token has the wrong issuer' do
message = 'Invalid issuer'
error_struct = double(message: message, status: :unauthorized)
response_struct = double(decoded_token: nil, error: error_struct)
allow(JsonWebToken).to receive(:verify).and_return(response_struct)
subject
expect(response).to be_unauthorized
expect(json_response!).to include('message' => message)
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. Note the tests are under a context called with error
which will group the tests and make it more organized so you can go ahead and add a valid
context for the missing test like so:
# spec/api/messages_controller_spec.rb
require 'rails_helper'
describe Api::MessagesController, type: :controller do
# ...existing code...
# 👇 new code
context 'valid' do
it 'returns an accepted answer for the protected endpoint' do
allow(JsonWebToken).to receive(:verify).and_return(double(decoded_token: :valid, error: nil))
subject
expect(response).to be_ok
message = 'The API successfully validated your access token.'
expect(json_response!).to include('message' => message)
end
end
# 👆 new code
# ...existing code...
If you look at the tests under the with error
context, they look pretty similar to each other, except for the message
string. This could be a hint for you to use a shared example.
Go ahead and create a new file under spec/support/shared
and call it invalid_token.rb
and fill it out with the following code:
RSpec.shared_examples 'invalid token' do |message|
it message.to_s do
error_struct = double(message: message, status: :unauthorized)
response_struct = double(decoded_token: nil, error: error_struct)
allow(JsonWebToken).to receive(:verify).and_return(response_struct)
subject
expect(response).to be_unauthorized
expect(json_response!).to include('message' => message)
end
end
You call the shared example an invalid token
and pass a message
argument. You are adding a test inside where the JsonWebToken.verify
method returns a response with the error message set as the one in the argument. Finally, the test expects that the response is unauthorized and includes the same error message.
Note this test might look a bit weird. You are mocking the exception with a message and expecting the response to have the same message. This happens because this code is simple, and it doesn't add much functionality around the validation process other than calling the JWT.decode
function. This code is meant to be used as an example of how to implement TDD and use the RSpec gem. For a real-life application, you'll probably have a much more complex codebase.
To use your new shared example, go to the messages_controller_spec.rb
and add the following:
# spec/api/messages_controller_spec.rb
require 'rails_helper'
describe Api::MessagesController, type: :controller do
describe '#protected' do
subject { get :protected, params: { format: :json } }
# 👇 new code
context "with error" do
include_examples "invalid token", "Invalid audience"
include_examples "invalid token", "Nil JSON web token"
include_examples "invalid token", "Signature has expired"
include_examples "invalid token", "Invalid issuer"
end
# 👆 new code
# ✨ You can delete the following code! 👇
#
# context 'with error' do
# it 'returns an error for the protected endpoint if the token has the wrong audience' do
# message = 'Invalid audience'
# error_struct = double(message: message, status: :unauthorized)
# response_struct = double(decoded_token: nil, error: error_struct)
# allow(JsonWebToken).to receive(:verify).and_return(response_struct)
# subject
# expect(response).to be_unauthorized
# expect(json_response!).to include('message' => message)
# end
# it 'returns an error for the protected endpoint if there is no token' do
# message = 'Nil JSON web token'
# error_struct = double(message: message, status: :unauthorized)
# response_struct = double(decoded_token: nil, error: error_struct)
# allow(JsonWebToken).to receive(:verify).and_return(response_struct)
# subject
# expect(response).to be_unauthorized
# expect(json_response!).to include('message' => message)
# end
# it 'returns an error for the protected endpoint if the token is expired' do
# error_struct = double(message: message, status: :unauthorized)
# response_struct = double(decoded_token: nil, error: error_struct)
# allow(JsonWebToken).to receive(:verify).and_return(response_struct)
# subject
# expect(response).to be_unauthorized
# expect(json_response!).to include('message' => message)
# end
# it 'returns an error for the protected endpoint if the token has the wrong issuer' do
# message = 'Invalid issuer'
# error_struct = double(message: message, status: :unauthorized)
# response_struct = double(decoded_token: nil, error: error_struct)
# allow(JsonWebToken).to receive(:verify).and_return(response_struct)
# subject
# expect(response).to be_unauthorized
# expect(json_response!).to include('message' => message)
# end
# end
# ... existing code
end
end
Implementing authorization
To implement the authorization, you're using a before_action callback. You pick the token from the header and verify it with the library. Traditionally, this helper method goes in the ApplicationController
, the base class for all controllers. Here is the code:
# app/controllers/application_controller.rb
require 'json_web_token'
class ApplicationController < ActionController::API
def authorize!
token = raw_token(request.headers)
validation_response = JsonWebToken.verify(token)
@token ||= validation_response.decoded_token
return unless (error = validation_response.error)
render json: { message: error.message }, status: error.status
end
private
def raw_token(headers)
return headers['Authorization'].split.last if headers['Authorization'].present?
end
end
And you ensure it's used only for the routes you want:
# app/controllers/api/messages_controller.rb
module Api
class MessagesController < ApplicationController
before_action :authorize!, except: %i[public]
end
end
Now you see the tests passing:
TODO: Update this image
Testing Permissions
At this point, you've got the protected route covered. Now, we shall focus on the admin route. The next step is checking for the token's correct permission
claim. Your regular token doesn't have the claim, so you'll stub it to be a valid token but not add permissions to it. The test should fail:
# 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 an error for the admin endpoint if the token does not have permissions' do
allow(JsonWebToken).to receive(:verify).and_return(double(decoded_token: {valid: :token, 'permissions' => ''}, error: nil))
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, you 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.
You Got There!
Writing code following TDD is all about having a tight feedback loop. This isn't easy to show in writing due to many small changes, but I hope I have given you a good glimpse.
Using TDD brings several benefits:
- You've confirmed that the implementation, you know, works
- The test suite describes the application's behavior in an executable way instead of potentially misleading documentation
- You're incorporating testability into your 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 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.