loading...

Securing a Ruby on Rails API with JWTs

fusionauth profile image FusionAuth Originally published at fusionauth.io on ・8 min read

Ruby on Rails is a modern web framework, but also a great way to build an API. The ability to quickly jam out your business logic, the ease of creating and modifying data models, and the built-in testing support all combine to make creating a JSON API in Rails a no brainer. Add in a sleek admin interface using something like RailsAdmin and you can build and manage APIs easily.

But you don’t typically want just anyone to consume your API. You want to ensure the right people and applications are doing so. In this tutorial, we’re going to build an API in Ruby on Rails 6, and secure it using JSON Web Tokens (JWTs).

As always, the code is available under an Apache2 license on GitHub, if you’d rather jump ahead.

Prerequisites

This post assumes you have Ruby and Rails 6 installed. If you don’t, we suggest you follow the steps in the Getting Started with Rails guide. Other than that we presume nothing about your knowledge of Ruby or Rails.

Build the API

To build the API, we’re going to create a new Rails application. Using the --api switch avoids generating a bunch of functionality we won’t need (like views).

rails new hello_api --api

Change to the created directory, hello_api. We’re now going to add our controller to the routes file. Edit the config/routes.rb file and change the contents to:

Rails.application.routes.draw do
  resources :messages, only: [:index]
end

This exposes the path /messages and ties it to a Messages controller. This Messages controller won’t be too complicated. It returns a hardcoded list of messages when a GET request is made to /messages. In a real-world application, of course, you would store messages in the database and pull them dynamically using ActiveRecord. But for this tutorial, a hardcoded list suffices.

Create the controller at app/controllers/messages_controller.rb. Here is what the class looks like:

class MessagesController < ApplicationController
  def index
    messages = []
    messages << "Hello"
    render json: { messages: messages }.to_json, status: :ok
  end
end

If you start up your Rails server:

rails s -p 4000

You should now be able to visit http://localhost:4000/messages and see some messages:

{"messages":["Hello"]}

But let’s add a test so future changes don’t cause surprises. Create the controller test at test/controllers/messages_controller_test.rb. Here are the contents of that class:

require 'test_helper'

class MessagesTest < ActionDispatch::IntegrationTest
  test "can get messages" do
    get "/messages"
    assert_response :success
  end
  test "can get messages content" do
    get "/messages"
    res = JSON.parse(@response.body)
    assert_equal '{"messages"=>["Hello"]}', res.to_s
  end
end

Now we can run our test and make sure that we are getting what we expect:

$ rails test test/integration/messages_test.rb
Running via Spring preloader in process 15492
Run options: --seed 1452

# Running:

..

Finished in 0.119373s, 16.7542 runs/s, 16.7542 assertions/s.
2 runs, 2 assertions, 0 failures, 0 errors, 0 skips

Excellent! We have a working API which returns well-formed JSON! Rails even takes care of setting the Content-Type header to application/json; charset=utf-8. Now let’s secure our API.

Secure the API

As a reminder, we’re going to use a JWT to secure this API. While you can secure Rails APIs using a variety of methods, using a JWT has certain advantages. You can integrate with a number of identity providers offering OAuth or SAML support. This allows you to leverage an existing robust identity management system to control API access. You can also embed additional metadata into a JWT, including attributes like roles.

To create tokens we’re using the Ruby JWT library. Add that to your Gemfile and then run bundle install. Add a line to the bottom of your Gemfile:

# ...
gem 'jwt'

Run bundle install to install it:

bundle install

After making sure we have the required gems, the next step is to write tests. Let’s modify the test to provide a JWT and expect :forbidden HTTP statuses when the token doesn’t meet our expectations.

class MessagesTest < ActionDispatch::IntegrationTest
  test "can' get messages with no auth" do
    get "/messages"
    assert_response :forbidden
  end
  test "can get messages with header" do
    get "/messages", headers: { "HTTP_AUTHORIZATION" => "Bearer " + build_jwt }
    assert_response :success
  end
  test "expired jwt fails" do
    get "/messages", headers: { "HTTP_AUTHORIZATION" => "Bearer " + build_jwt(-1) }
    assert_response :forbidden
  end
  test "can get messages content" do
    get "/messages", headers: { "HTTP_AUTHORIZATION" => "Bearer " + build_jwt }
    res = JSON.parse(@response.body)
    assert_equal '{"messages"=>["Hello"]}', res.to_s
  end

  def build_jwt(valid_for_minutes = 5)
    exp = Time.now.to_i + (valid_for_minutes*60)
    payload = { "iss": "fusionauth.io",
                "exp": exp,
                "aud": "238d4793-70de-4183-9707-48ed8ecd19d9",
                "sub": "19016b73-3ffa-4b26-80d8-aa9287738677",
                "name": "Dan Moore",
                "roles": ["USER"]
    }

    JWT.encode payload, Rails.configuration.x.oauth.jwt_secret, 'HS256'

  end
end

We look for the JWT in the Authorization HTTP header. Rails exposes it via the HTTP_AUTHORIZATION key in the headers hash of the request. Let’s look more closely at the token.

JWTs have claims, basically information embedded in the JWT. The keys of the JSON payload assembled in the build_jwt function, such as iss and name, are claims. Some of these are defined in the JWT RFC. These are ‘registered’ claims. Others are recorded with the IANA but are not part of the standard; these are ‘public’ claims. And yet others are defined by the token creator; these are ‘private’ claims.

# ...
  def build_jwt(valid_for_minutes = 5)
    exp = Time.now.to_i + (valid_for_minutes*60)
    payload = { "iss": "fusionauth.io",
                "exp": exp,
                "aud": "238d4793-70de-4183-9707-48ed8ecd19d9",
                "sub": "19016b73-3ffa-4b26-80d8-aa9287738677",
                "name": "Dan Moore",
                "roles": ["USER"]
    }

    JWT.encode payload, Rails.configuration.x.oauth.jwt_secret, 'HS256'
# ...

Above, we add registered claims to a JWT that any consumer of the token, including our API classes, may examine. exp indicates when the JWT will expire. aud is an identifier of who or what this token is intended for (the “audience”). sub is the person or piece of software to which this token applies; to quote the RFC: “The claims in a JWT are normally statements about the subject.” iss is an identifier for the issuer of the JWT, typically an user identity management server. Since we’re generating the JWT ourselves here, we can specify any value we’d like.

We also add the name public claim, which lets JWT consumers know the user’s name. roles are a private claim with a meaning undefined outside of our application. Note that because content of JWTs is not typically encrypted, claims should contain no secrets or private data.

The last thing we do is encode our JWT. This signs it, adds needed metadata and creates the URL encoded version. Here’s what one of the JWTs generated by build_jwt looks like:

eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJmdXNpb25hdXRoLmlvIiwiZXhwIjoxNTkwMTgxNjE5LCJhdWQiOiIyMzhkNDc5My03MGRlLTQxODMtOTcwNy00OGVkOGVjZDE5ZDkiLCJzdWIiOiIxOTAxNmI3My0zZmZhLTRiMjYtODBkOC1hYTkyODc3Mzg2NzciLCJuYW1lIjoiRGFuIE1vb3JlIiwicm9sZXMiOlsiVVNFUiJdfQ.P7KXBV8fNElGGr1McKIMQbU7-mZPMxv8tw5AbufZgr0

We use the HMAC signature algorithm because in this tutorial we control both the issuer of the token and the consumer (our API). We can, therefore, share a secret reliably between them. If we didn’t have a good way to share secrets, using an asymmetric signing key would be a wiser choice.

For this tutorial, we put the HMAC secret in the environment configuration files. For production usage, use your normal secrets management solution. You should make the HMAC secret a long string, but don’t use any other shared secrets, such as the session secret.

Let’s add our authorization code now that our tests fail. They fail because they are expecting certain unauthorized requests to return :forbidden.

There are two places we could put the code that checks the token. We could add it to the Messages controller. Or we could add it to the Application controller. This latter choice would enforce authorization for all requests. This is the better option.

Since we are building an API that should never be accessed without authorization, we should protect all our resources. If and when we need to distinguish between different claims (for instance, we may want to have some APIs only accessible for users with the ADMIN role), we can refactor and move the verification code to different controllers.

Here’s the authorization code for the app/controllers/application_controller.rb file:

class ApplicationController < ActionController::API
  before_action :require_jwt

  def require_jwt
    token = request.headers["HTTP_AUTHORIZATION"]
    if !token
      head :forbidden
    end
    if !valid_token(token)
      head :forbidden
    end
  end

  private
  def valid_token(token)
    unless token
      return false
    end

    token.gsub!('Bearer ','')
    begin
      decoded_token = JWT.decode token, Rails.configuration.x.oauth.jwt_secret, true
      return true
    rescue JWT::DecodeError
      Rails.logger.warn "Error decoding the JWT: "+ e.to_s
    end
    false
  end
end

This code expects the JWT in the Authorization HTTP header prepended by Bearer, which is defined in RFC 6750. If it doesn’t exist, we deny access. If it does, we try to decode it. If it decodes without raising an exception, it is a valid JWT.

Verify claims

But really, what does valid mean? That’s something developers define on an application by application basis, though the jwt gem provides a baseline: it checks the exp and nbf claims and verifies the signature.

But for this application, we need to be extra sure. After all, if our messages fell into the wrong hands, who knows what could happen?

So, let’s validate additional claims when we are decoding the JWT. We’ll check to make sure that claims like the issuer are what we expect. We can perform these checks by providing options to the JWT.decode method. Instead of:

# ...
decoded_token = JWT.decode token, Rails.configuration.x.oauth.jwt_secret, true
# ...

We’ll actually check the iss and aud claims are expected. This adds an additional layer of security. Here’s another article about securing your signed JWTs.

# ...
expected_iss = 'fusionauth.io'
expected_aud = '238d4793-70de-4183-9707-48ed8ecd19d9'
# ...
decoded_token = JWT.decode token, Rails.configuration.x.oauth.jwt_secret, true, { verify_iss: true, iss: expected_iss, verify_aud: true, aud: expected_aud, algorithm: 'HS256' }
# ...

The options at the end of the JWT decode method specify which claims we want to verify. If there were private claims that we wanted to check, we could do that as well. For instance, perhaps your application has a domain-specific claim that the API needs to ensure is present.

A warning, while we are guaranteed by the signature that the contents of the token are exactly what they were when it was created, we aren’t guaranteed that the contents will remain unexamined.

Therefore, add claims, but remember JWTs should contain a bare minimum of them. If needed, consumers can always make additional requests of the identity management server if they need information too private for a token. For the JWT we’ve generated, the consumer could retrieve more information about the subscriber 19016b73-3ffa-4b26-80d8-aa9287738677 with a direct request.

We also added some tests, but you’ll need to check out the GitHub repository to see them.

Take it further

If you are interested in extending this example, make the API more realistic. Create a Messages model and store them in the database. Change your claims to include a preferred greeting, and prepend that to any messages. Add more API endpoints and only allow users with certain roles to access them.

The code is on GitHub for your perusal.

Next steps

You’ll notice we never specified the source of the JWT. We just generated one using the jwt gem. In general, tokens are provided by an authentication process. Integrating a user identity store, such as FusionAuth, to provide such tokens is what we’ll tackle next.

Posted on by:

fusionauth profile

FusionAuth

@fusionauth

Auth for any app - FusionAuth provides authentication, authorization, and user management for any app. Install on Linux, macOS, Windows, and Cloud. Integrates with any codebase.

Discussion

markdown guide