Rails API Authorization By Example
Updated on January 30, 2023This Ruby guide will help you learn how to secure a Rails API using token-based authorization. You'll learn how to integrate Auth0 by Okta with Ruby on Rails to implement the following security features:
- Use Rails Concerns to enforce API security policies.
- Perform access control in Rails using a token-based authorization strategy powered by JSON Web Tokens (JWTs).
- Validate access tokens in JSON Web Token (JWT) format using a Rails helper class.
- Make authenticated requests to a secure Rails API server.
Optionally, you can also learn how to implement Role-Based Access Control (RBAC) in Rails by checking permissions associated with roles before the Rails API can serve an incoming request.
How Does Auth0 Work?
With the help of Auth0 by Okta, you don't need to be an expert on identity protocols, such as OAuth 2.0 or OpenID Connect, to understand how to secure your web application stack.
You first integrate your client applications with Auth0. Your application will then redirect users to an Auth0 customizable login page when they need to log in. Once your users log in successfully, Auth0 redirects them to your client app, returning an access token. The client application can then use that access token to authenticate itself with your Rails API and access protected resources in your server or database layers.
Get the Starter Project
We have created a starter project to help you learn Rails security concepts through hands-on practice using the following tools:
- Ruby
3.1.2
- Rails
7.0.4
- Bundler
2.3.19
Start by cloning the api_rails_ruby_hello-world
repository on its starter
branch:
git clone -b starter https://github.com/auth0-developer-hub/api_rails_ruby_hello-world.git
Once you clone the repo, make api_rails_ruby_hello-world
your current directory:
cd api_rails_ruby_hello-world
Install the Rails project dependencies by running the following command:
bundle install
The starter Rails project defines API endpoints to let client applications access a simple message
resource:
# get a public messageGET /api/messages/public# get a protected messageGET /api/messages/protected# get an admin messageGET /api/messages/admin
The starter project does not implement access control for these endpoints. After you follow the steps in this guide, the API server will require each request to have a valid access token on their authorization header to access GET /api/messages/protected
and GET /api/messages/admin
. GET /api/messages/public
would be the only public endpoint. Later, you'll integrate this Rails API with an actual client application using a frontend technology of your choice.
You'll run your Rails server on port 6060
locally. The compatible demo client application runs on http://localhost:4040
by default. However, this Rails starter application uses CORS to restrict which origins can request its resources.
You'll manage these configuration elements of your Rails application using environment variables. You'll store those values in a .env
file, and you'll access them using the dotenv-rails
gem.
As such, create a .env
file under the root project directory:
touch .env
Populate your .env
file with the following content:
PORT=6060CLIENT_ORIGIN_URL=http://localhost:4040
The PORT
environment variable defines the port in which your Rails API server will run: http://localhost:6060
. The CLIENT_ORIGIN_URL
variable defines the origin from which a browser can load resources from your Rails API — other than your API's own origin.
Finally, open another terminal tab and execute this command to run your Rails application in development mode:
bin/rails s
You are ready to start implementing authorization in this Rails project. First, you'll need to configure the Rails API server to connect successfully to Auth0. Afterward, you'll use a Rails Concern to validate bearer tokens from incoming API requests.
Configure a Ruby on Rails API with Auth0
Follow these steps to get started with the Auth0 Identity Platform quickly:
Sign up and create an Auth0 API
A free account also offers you:
- Auth0 Universal Login for Web, iOS & Android.
- Up to 2 social identity providers like Google, GitHub, and Twitter.
- Up to 3 Actions, Rules, & Hooks to customize and extend Auth0's capabilities.
During the sign-up process, you create something called an Auth0 Tenant, representing the product or service to which you are adding authentication.
Once you sign in, Auth0 takes you to the Dashboard. Open the "APIs" section of the Auth0 Dashboard.
Then, click the "Create API" button. A modal opens with a form to provide a name for the API registration and define an API Identifier. Use the following values:
Auth0 Rails Code Sample
https://hello-world.example.com
Click the "Create" button to complete the process. Your Auth0 API registration page loads up.
A page loads up, presenting all the details about your application register with Auth0.
Next, locate the "General Settings" section.
Store the "Identifier" value in the following fields to set up your Rails API server in the next section:
In the next step, you'll learn how to help Rails and Auth0 communicate.
What's the relationship between Auth0 Tenants and Auth0 Applications?
Let's say that you have a photo-sharing Rails app called "Rubygram". You then would create an Auth0 tenant called rubygram
. From a customer perspective, Rubygram is that customer's product or service.
Now, say that Rubygram is available on three platforms: web as a single-page application and Android and iOS as a native mobile application. If each platform needs authentication, you need to create three Auth0 applications to provide the product with everything it needs to authenticate users through that platform.
Rubygram users belong to the Auth0 Rubygram tenant, which shares them across its Auth0 applications.
Create a communication bridge between Rails and Auth0
In this guide, you'll implement token-based authorization. That is, your Rails API server will protect an endpoint by requiring that each request to that endpoint contains a valid access token.
The issuer of those access tokens will be an Auth0 authorization server. Your Rails API server needs to validate that the access token on a request comes from Auth0.
You'll need the Auth0 Domain and Auth0 Audience values to validate the access tokens.
When setting up APIs in the Auth0 Dashboard, we also refer to the API identifier as the Audience value, which you have already set up in the previous section.
Get the Auth0 domain
Now, follow these steps to get the Auth0 Domain value.
-
Open the Auth0 Domain Settings
-
Locate the bold text in the page description that follows this pattern:
tenant-name.region.auth0.com
. That's your Auth0 domain! -
Paste the Auth0 domain value in the following field so that you can use it in the next section to set up your API server:
region
subdomain (au
, us
, or eu
) is optional. Some Auth0 Domains don't have it.Get an Auth0 access token
You'll also need a test access token to practice making secure calls to your API from a terminal application.
You can get a test access token from the Auth0 Dashboard by following these steps:
- Head back to the Auth0 registration page of your Rails API and click on the "Test" tab.
-
Locate the section called "Response" and click on the copy button in the top-right corner.
-
Paste the access token value in the following field so that you can use it in the next sections to test your API server:
When you enter a value in the input fields present on this page, any code snippet that uses such value updates to reflect it. Using the input fields makes it easy to copy and paste code as you follow along.
Define the Auth0 Environment Variables
Update the .env
file under the project directory as follows to integrate the Auth0 Domain and Auth0 Audience values with your Rails application:
PORT=6060CLIENT_ORIGIN_URL=http://localhost:4040AUTH0_AUDIENCE=AUTH0-AUDIENCEAUTH0_DOMAIN=AUTH0-DOMAIN
Once you reach the "Request Rails API Resources From a Client App" section of this guide, you'll learn how to use CLIENT_ORIGIN_URL
along with an Auth0 Audience value to request protected resources from your Rails API using a client application that is also protected by Auth0.
Create an Auth0 configuration file
Create an auth0.yml
file under the config
directory to make Rails aware of the environment variables present in your .env
file:
touch config/auth0.yml
Populate the config/auth0.yml
file with the following content:
development:domain: <%= ENV.fetch('AUTH0_DOMAIN') %>audience: <%= ENV.fetch('AUTH0_AUDIENCE') %>production:domain: <%= ENV.fetch('AUTH0_DOMAIN') %>audience: <%= ENV.fetch('AUTH0_AUDIENCE') %>
Next, you'll use Rails::Application.config_for
to load the config/auth0.yml
configuration file. Locate the config/application.rb
file and update its content with the following:
# frozen_string_literal: truerequire_relative 'boot'require 'rails'# Pick the frameworks you want:require 'active_model/railtie'require 'active_job/railtie'# require "active_record/railtie"# require "active_storage/engine"require 'action_controller/railtie'require 'action_mailer/railtie'# require "action_mailbox/engine"# require "action_text/engine"require 'action_view/railtie'require 'action_cable/engine'# require "rails/test_unit/railtie"# Require the gems listed in Gemfile, including any gems# you've limited to :test, :development, or :production.Bundler.require(*Rails.groups)module ApiRailsRubyHelloWorldclass Application < Rails::Application# Initialize configuration defaults for originally generated Rails version.config.load_defaults 7.0# Configuration for the application, engines, and railties goes here.## These settings can be overridden in specific environments using the files# in config/environments, which are processed later.## config.time_zone = "Central Time (US & Canada)"# config.eager_load_paths << Rails.root.join("extras")# Only loads a smaller set of middleware suitable for API only apps.# Middleware like session, flash, cookies can be added back manually.# Skip views, helpers and assets when generating a new resource.config.api_only = trueconfig.exceptions_app = routesconfig.auth0 = config_for(:auth0)config.action_dispatch.default_headers = {'X-Frame-Options' => 'deny','X-XSS-Protection' => '0','Strict-Transport-Security' => 'max-age=31536000; includeSubDomains','X-Content-Type-Options' => 'nosniff','Cache-Control' => 'no-store','Pragma' => 'no-cache','Content-Security-Policy' => "default-src 'self', frame-ancestors 'none'"}endend
config_for
is convenience method for loading config/auth0.yml
for the current Rails
environment.
config.auth0 = config_for(:auth0)
This Rails custom configuration allows you to use Rails.configuration.auth0
to access the authorization-related variables throughout your Rails application: Rails.configuration.auth0.domain
and Rails.configuration.auth0.audience
.
Finally, restart your development server to make your Rails project aware of the environment variables:
bin/rails s
Validate a JSON Web Token (JWT) in Rails
You can use the jwt
gem to validate JSON Web Tokens in a Rails project. While OAuth 2.0 does not specify a format for access tokens, the Auth0 authorization server issues them in JWT format.
Add the gem to your Gemfile
by running the following command:
bundle add jwt
Next, create a lib
directory under the app
directory to store any files related to JSON Web Token (JWT) validation:
mkdir app/lib
Next, create an auth0_client.rb
file under the app/lib
directory to define an Auth0Client
class that encapsulates all the logic required to perform JWT validation in Rails:
touch app/lib/auth0_client.rb
app
directory. As such, it's more convenient to create the Auth0Client
class under an app/lib
directory rather than in the lib
directory from Rails. If you're not familiar with Rails 6 autoloading with the Zeitwork engine, the Rails guide to autoloading or this article on understanding Zeitwork in Rails 6 can provide more information.Populate the app/lib/auth0_client.rb
file with the following code:
# frozen_string_literal: truerequire 'jwt'require 'net/http'class Auth0Client# Auth0 Client ObjectsError = Struct.new(:message, :status)Response = Struct.new(:decoded_token, :error)# Helper Functionsdef self.domain_url"https://#{Rails.configuration.auth0.domain}/"enddef self.decode_token(token, jwks_hash)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] }})enddef self.get_jwksjwks_uri = URI("#{domain_url}.well-known/jwks.json")Net::HTTP.get_response jwks_uriend# Token Validationdef self.validate_token(token)jwks_response = get_jwksunless jwks_response.is_a? Net::HTTPSuccesserror = Error.new(message: 'Unable to verify credentials', status: :internal_server_error)return Response.new(nil, error)endjwks_hash = JSON.parse(jwks_response.body).deep_symbolize_keysdecoded_token = decode_token(token, jwks_hash)Response.new(decoded_token, nil)rescue JWT::VerificationError, JWT::DecodeError => eerror = Error.new('Bad credentials', :unauthorized)Response.new(nil, error)endend
What is the Auth0Client
class doing under the hood?
First, the Auth0Client
class uses a Ruby Struct
to define two objects: Response
and Error
.
The Response
Struct
contains the result of the token validation process: if successful, the :decoded_token
symbol contains the value of the decoded access token and the :error
symbol is nil
. Otherwise, if the validation fails, :decoded_token
is nil
, and :error
contains details about the validation error.
The following is an example of what a decoded access token in JWT format looks like:
{"iss"=>"https://<AUTH0_DOMAIN>/","sub"=>"cD7244U03iVauHhfbej7iaf3iQfsAvIdk8@@clients","aud"=>"<AUTH0_AUDIENCE>","iat"=>1666793694,"exp"=>1666880094,"azp"=>"cD700U0feuH1IIgfhj5344QfsAvIdk8","gty"=>"client-credentials","permissions"=>[]}{"alg"=>"RS256","typ"=>"JWT","kid"=>"MJjGiAB5k6FUrMf12YUz9"}
You use the Error
Struct
to make the error handling process within the Auth0Client
class easier. Error
wraps the information about the token validation error: it contains a description of what happened, the :message
symbol, and the HTTP status code for the client response, the :status
symbol.
You can see how the rescue
operation uses Error
as the second argument of Response.new
when the validation fails.
The Auth0Client
class defines the following attributes and methods:
The get_jwks
method makes an HTTP request to Auth0's well-known endpoint to retrieve the JSON Web Key Set (JWKS), which you must use to validate any of the access tokens that Auth0 issues for your tenant.
The domain_url
attribute represents the Auth0 Issuer URL.
The decode_token
method decodes the JSON Web Token (JWT). This method receives two arguments: token
and jwks_hash
.
- The
token
argument is the JWT that you want to decode. - The
jwks_hash
argument is a hash that represents a JSON Web Key Sets (JWKS).
The jwks_hash
argument has the following format:
{:keys=>[{:alg=>"RS256",:kty=>"RSA",:use=>"sig",:n=>"...",:e=>"...",:kid=>"...",:x5t=>"...,:x5c=>["..."]},...]}
The decode_token
method calls JWT.decode
, a function from the JWT gem with the following arguments:
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] }})
What do these arguments represent?
- The first argument is
token
, which is the JWT that you want to decode. - The second argument refers to the
key
, for which you usenil
as you don't know the key-value pair used to encode the token.- If you were using an RSA algorithm, you would encode the token using the private key and decode it using the public key.
- The third argument is a boolean,
verify
.- If
true
,JWT.decode
will verify the token's signature.
- If
- The fourth and last argument is a hash with additional configuration
options
:
{algorithm: 'RS256',iss: domain_url,verify_iss: true,aud: Rails.configuration.auth0.audience.to_s,verify_aud: true,jwks: { keys: jwks_hash[:keys]}
Let's explore what these JWT.decode
options do:
- The
algorithm
property represents the signing algorithm that the token-issuer uses to sign the token. - The
iss
property refers to the issuer URL. - The
verify_iss
property is a boolean. Iftrue
,JWT.decode
checks that the configured issuer matches the JWT issuer present on the JWT payload. - The
aud
property is the audience.- In this case, your
AUTH0_AUDIENCE
variable.
- In this case, your
- The
verify_aud
property is a boolean. Iftrue
,JWT.decode
checks that the configured audience matches the audience value present in the JWT payload. - The
jwks
property refers to the JWKS you received from theget_jwks
call, which you are storing injwks_hash
.- In this case, you only need the
keys
value.
- In this case, you only need the
Finally, the Auth0Client
class defines a validate_token(token)
method that uses the private class attributes and methods to carry out JWT validation.
The validate_token
method receives a JSON Web Token (JWT) as an argument and returns the decoded token after it performs the required validations.
First, validate_token
retrieves the JWT public keys using the get_jwks
method. If the request is not successful, you return an error response with an appropriate message:
jwks_response = get_jwksunless jwks_response.is_a? Net::HTTPSuccesserror = Error.new(message: "Unable to verify credentials", status: :internal_server_error)return Response.new(nil, error)end
If the request is successful, you parse the json
response body into a hash
:
jwks_hash = JSON.parse(jwks_response.body).deep_symbolize_keys
You then use the hash to decode the token using the decode_token
method and store the result in a Response
struct:
decoded_token = decode_token(token, jwks_hash)Response.new(decoded_token, nil)
Finally, you catch any validation errors that can happen and return a proper response:
rescue JWT::VerificationError, JWT::DecodeError => eerror = Error.new('Bad credentials', :unauthorized)Response.new(nil, error)end
Protect API Endpoints
Create a secured.rb
file under the app/controllers/concerns
directory to define a Concern
that would be used later on the BaseController
to validate the supplied access token.
A Rails Concern is a Ruby module that is mixed in between models and controllers, making them available for you to use in any of them. Concerns allow you to extract the common logic from different classes into a reusable module. In this case, you are implementing a Concern so it can be used across other controllers, although for now, you'll only use it in the Auth0Controller
.
To create the concern, run the following in your terminal:
touch app/controllers/concerns/secured.rb
Populate the app/controllers/concerns/secured.rb
file with the following content:
# frozen_string_literal: truemodule Securedextend ActiveSupport::ConcernREQUIRES_AUTHENTICATION = { message: 'Requires authentication' }.freezeBAD_CREDENTIALS = {message: 'Bad credentials'}.freezeMALFORMED_AUTHORIZATION_HEADER = {error: 'invalid_request',error_description: 'Authorization header value must follow this format: Bearer access-token',message: 'Bad credentials'}.freezedef authorizetoken = token_from_requestreturn if performed?validation_response = Auth0Client.validate_token(token)return unless (error = validation_response.error)render json: {message: error.message}, status: error.statusendprivatedef token_from_requestauthorization_header_elements = request.headers['Authorization']&.splitrender json: REQUIRES_AUTHENTICATION, status: :unauthorized and return unless authorization_header_elementsunless authorization_header_elements.length == 2render json: MALFORMED_AUTHORIZATION_HEADER, status: :unauthorized and returnendscheme, token = authorization_header_elementsrender json: BAD_CREDENTIALS, status: :unauthorized and return unless scheme.downcase == 'bearer'tokenendend
How is the access token being validated in the Secured Concern?
There are three validation steps performed on the token_from request
method to validate that the access token is well-formed:
- Validate that the authorization header is present on the request:
authorization_header_elements = request.headers['Authorization']&.splitrender json: REQUIRES_AUTHENTICATION, status: :unauthorized and return unless authorization_header_elements
- Validate the authorization header is correctly formed:
unless authorization_header_elements.length == 2render json: MALFORMED_AUTHORIZATION_HEADER, status: :unauthorized and returnend
- Validate the
Bearer
scheme is used on the authorization header:
render json: BAD_CREDENTIALS, status: :unauthorized and return unless scheme.downcase == 'bearer'
If all those validations are successful, the access token is returned.
The purpose of the authorize
method is to call the validate_token
method from the Auth0Client
class with the provided access token and render any error that may have occurred.
First, you'll need to check if render
was already called from the token_from_request
method and do nothing if that's the case.
return if performed?
Rails already know the current environment it is working with and will get the proper values. This works great because you may need to set different values for each environment, or set additional variables without changing the Auth0Client
class each time you modify it:
validation_response = Auth0Client.new(Rails.configuration.auth0).validate_token(token)
A Concern
acts like a mixin, so you'll need to add it to the BaseController
, which is the parent controller of all the API
controllers, so you don't have to add the same concern to every single controller you'll create. So, let's open the app/controllers/api/base_controller.rb
file and add the Secured
concern:
# frozen_string_literal: truemodule Apiclass BaseController < ApplicationControllerinclude Securedrescue_from StandardError do |e|render json: { message: e.message }, status: :internal_server_errorendendend
Now, you need to tell Rails to call the authorize
method for each endpoint except the public one on the app/controllers/api/messages_controller.rb
file. You can do it by registering it as a callback to the before_action
built-in method:
# frozen_string_literal: truemodule Apiclass MessagesController < BaseControllerbefore_action :authorize, except: [:public]def adminrender json: Message.admin_messageenddef protectedrender json: Message.protected_messageenddef publicrender json: Message.public_messageendendend
The authorize
method will be called before performing any action on the protected
and admin
endpoints.
Test Your Protected API Endpoints
Run the API server with the following command:
bin/rails s
You can use a terminal application like curl
to test that your API server is working as expected.
As an anonymous user:
curl localhost:6060/api/messages/public
You should get a 200 Ok
status code with the following message:
{"text": "This is a public message."}
Now, let's test the response of a protected endpoint:
curl localhost:6060/api/messages/protected
You should get a 401 Unauthorized
status code with the following message:
{"message": "Requires authentication"}
As an authenticated user:
An authenticated request is a request that includes a bearer token in its authorization header. That bearer token is the access token in JSON Web Token (JWT) format that you obtained earlier from the Auth0 Dashboard.
Let's test the response of a protected endpoint when using a valid access token:
curl --request GET \--url http:/localhost:6060/api/messages/protected \--header 'authorization: Bearer AUTH0-ACCESS-TOKEN'
You should get a 200 OK
status code with the following message if your access token is valid:
{"text": "This is a protected message."}
However, when using an invalid test access token as below:
curl --request GET \--url http:/localhost:6060/api/messages/protected \--header 'authorization: Bearer invalidtoken1234567890'
You should get a 401 Unauthorized
status code with the following message:
{"message": "Bad credentials"}
Set Up Role-Based Access Control (RBAC)
Within the context of Auth0, Role-based access control (RBAC) systems assign permissions to users based on their role within an organization. Everyone who holds that role has the same set of access rights. Those who hold different roles have different access rights.
Developers who use Role-based access control (RBAC) for access management can mitigate the errors that come from assigning permissions to users individually.
You can use the Auth0 Dashboard to enable Role-Based Access Control (RBAC) in any API that you have already registered with Auth0. You then implement RBAC by creating API permissions, assigning those permissions to a role, and assigning that role to any of your users.
Whenever a user logs in to one of your client applications, the Auth0 authorization server issues an access token that the client can use to make authenticated requests to an API server. Auth0 authorization servers issue access tokens in JSON Web Token (JWT) format.
When you enable Auth0 Role-Based Access Control (RBAC) for an API, the access token will include a permissions
claim that has all the permissions associated with any roles that you have assigned to that user.
For this particular API, the access token present in the authorization header of a request must include a permissions
claim that contains the read:admin-messages
permission to access the GET /api/messages/admin
endpoint.
Enable Role-Based Access Control (RBAC)
-
Open the APIs section of the Auth0 Dashboard and select your "Auth0 Rails Code Sample" registration.
-
Click on the "Settings" tab and locate the "RBAC Settings" section.
-
Switch on the "Enable RBAC" and "Add Permissions in the Access Token" options.
Create an API permission
In the same Auth0 API registration page, follow these steps:
- Click on the "Permissions" tab and fill a field from the "Add a Permission (Scope)" section with the following information:
read:admin-messages
Read admin messages
- Click the "+ Add" button to store the permission.
Create a role with permissions
Create a role
-
Open the User Management > Roles section of the Auth0 Dashboard.
-
Click on the Create role button and fill out the "New Role" form with the following values:
messages-admin
Access admin messaging features
- Click on the Create button.
Add permissions to the role
-
Click on the "Permissions" tab of the roles page.
-
Click on the "Add Permissions" button.
-
Select the "Auth0 Rails Code Sample" from the dropdown menu that comes up and click the "Add Permissions" button.
-
Select all the permissions available by clicking on them one by one or by using the "All" link.
-
Finally, click on the "Add Permissions" button to finish up.
Create an admin user
-
Open the User Management > Users section from the Auth0 Dashboard.
-
Click on the "Create user" button and fill out the form with the required information. Alternatively, you can also click on any of your existing users to give one of them the admin role.
-
On the user's page, click on the "Roles" tab and then click on the "Assign Roles" button.
-
Select the
messages-admin
role from the dropdown menu and click on the "Assign" button.
Implement RBAC in a Ruby on Rails API
Implementing RBAC consists of checking that the access token has the required permissions. Since you already have most of the logic in place to validate an access token, you'll just need to add a method to validate if the permissions
claim of the access token contains the required permission to access an endpoint.
Let's implement a method that will validate the access token's permission.
So let's open the app/lib/auth0_client.rb
and add the following code:
# frozen_string_literal: truerequire 'jwt'require 'net/http'class Auth0Client# Class membersResponse = Struct.new(:decoded_token, :error)Error = Struct.new(:message, :status)Token = Struct.new(:token) dodef validate_permissions(permissions)required_permissions = Set.new permissionstoken_permissions = Set.new token[0]['permissions']required_permissions <= token_permissionsendend# Helper Functionsdef self.domain_url"https://#{Rails.configuration.auth0.domain}/"enddef self.decode_token(token, jwks_hash)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] }})enddef self.get_jwksjwks_uri = URI("#{domain_url}.well-known/jwks.json")Net::HTTP.get_response jwks_uriend# Token Validationdef self.validate_token(token)jwks_response = get_jwksunless jwks_response.is_a? Net::HTTPSuccesserror = Error.new(message: 'Unable to verify credentials', status: :internal_server_error)return Response.new(nil, error)endjwks_hash = JSON.parse(jwks_response.body).deep_symbolize_keysdecoded_token = decode_token(token, jwks_hash)Response.new(Token.new(decoded_token), nil)rescue JWT::VerificationError, JWT::DecodeError => eerror = Error.new('Bad credentials', :unauthorized)Response.new(nil, error)endend
This code converts the required_permissions
and token_permissions
variables into Set
so that we can easily compare them with the subset operator, <=
. An access token will have valid permissions if required_permissions
is a subset of token_permissions
. Remember that the decoded token from JWT.decode
returns an array like [payload, header]
, so you'll need to get the permissions
key from the payload hash. You also have changed the response to include the new Token
struct.
Now that the response contains the Token
struct, you can validate its permissions. Open the app/controllers/concerns/secured.rb
file and add the following method to the Secured
module where you previously wrote the token validation:
# frozen_string_literal: truemodule Securedextend ActiveSupport::ConcernREQUIRES_AUTHENTICATION = { message: 'Requires authentication' }.freezeBAD_CREDENTIALS = {message: 'Bad credentials'}.freezeMALFORMED_AUTHORIZATION_HEADER = {error: 'invalid_request',error_description: 'Authorization header value must follow this format: Bearer access-token',message: 'Bad credentials'}.freezeINSUFFICIENT_PERMISSIONS = {error: 'insufficient_permissions',error_description: 'The access token does not contain the required permissions',message: 'Permission denied'}.freezedef authorizetoken = token_from_requestreturn if performed?validation_response = Auth0Client.validate_token(token)@decoded_token = validation_response.decoded_tokenreturn unless (error = validation_response.error)render json: { message: error.message }, status: error.statusenddef validate_permissions(permissions)raise 'validate_permissions needs to be called with a block' unless block_given?return yield if @decoded_token.validate_permissions(permissions)render json: INSUFFICIENT_PERMISSIONS, status: :forbiddenendprivatedef token_from_requestauthorization_header_elements = request.headers['Authorization']&.splitrender json: REQUIRES_AUTHENTICATION, status: :unauthorized and return unless authorization_header_elementsunless authorization_header_elements.length == 2render json: MALFORMED_AUTHORIZATION_HEADER, status: :unauthorized and returnendscheme, token = authorization_header_elementsrender json: BAD_CREDENTIALS, status: :unauthorized and return unless scheme.downcase == 'bearer'tokenendend
First, you'll check the validate_permissions
method was called with a block, such as:
validate_permissions(array) do# block to run if validation is successfulend
Then, if the permissions from the access token match the permissions
argument, the given block is processed on the yield
keyword. Otherwise, the Rails API will respond with a 403 Forbidden
status code and a proper message response.
Finally, open the app/controllers/api/messages_controller.rb
file and modify the admin action to look like this:
# frozen_string_literal: truemodule Apiclass MessagesController < BaseControllerbefore_action :authorize, except: [:public]def adminvalidate_permissions ['read:admin-messages'] dorender json: Message.admin_messageendenddef protectedrender json: Message.protected_messageenddef publicrender json: Message.public_messageendendend
The required permissions consist of an array of strings where each string is a permission, such as: ['read:admin-messages']
. This allows you to define more permissions in the future without changing the rest of the code. Then you'll pass the block you want to be executed when the validation is successful.
Request Rails API Resources From a Client App
Let's test access to your API endpoints by simulating a real user login and requesting protected resources using a real access token.
You can pair this API server with a client application that matches the technology stack that you use at work. Any "Hello World" client application can communicate with this API server.
Pick a Single-Page Application (SPA) code sample in your preferred frontend framework and language:
Once you set up the client application, log in and visit the guarded "Admin" page (http://localhost:4040/admin).
When you log in to a "Hello World" client application as a user who has the messages-admin
role, your access token will have the required permissions to access the GET /api/messages/admin
endpoint, and you'll get the following response:
{"text": "This is an admin message."}
However, when you log in as a user who doesn't have the messages-admin
role, you'll get a 403 Forbidden
status code and the following response:
{"message": "Permission denied"}
Conclusion
You have implemented user authorization in Ruby on Rails to identify your users and control the content your users can access by protecting routes and API resources.
This guide covered the most common authorization use cases for a Ruby on Rails API application: use concerns to enforce security policies, validate access tokens, make authenticated requests to your API, and implement Role-Based Access Control (RBAC). However, Auth0 is an extensible and flexible identity platform that can help you achieve even more. If you have a more complex use case, check out the Auth0 Architecture Scenarios to learn more about the typical architecture scenarios we have identified when working with customers on implementing Auth0.
We'll cover advanced authorization strategies in future guides, such as Fine-Grained Authorization (FGA).