JWT Authentication
Implementing authentication for third-party access is no small feat, but is imperative in order to compete in a complex API economy and expand business capabilities. With every application, securing protected resources always poses a unique challenge, particularly considering how rapidly technology evolves. New solutions come along and customers expect a level of consistency across apps, which is important to be mindful of in reducing friction (and generating revenue).
The OAuth 2.0 protocol is the industry standard for authorization. It focuses on client/developer simplicity and enables secure access for desktop and mobile applications. Nearly everyone has come across this type of authentication with Single Sign On (SSO) options from companies like Google, Apple, or Linkedin, which keep you logged in across all of their products (view a full list of strategies).
For the purposes of this article, the focus will be on authenticating access to protected data through Rails 6 REST API endpoints, through means like axios and cURL. Axios is an excellent Promise-based HTTP client which comes bundled with features that can intercept or transform data in the request/response cycle, sending authentication credentials with every connection.
Basic server-side authentication using sessions and cookies doesn't require much initial setup, but when it comes to client-side authentication, there are many cross-origin security vulnerabilities to take into account. I've tried a combination of server/client-side authentication methods using Devise, but had greater success by committing to either or. It really depends on the type of product you are building, but a Single Page Application authenticated by way of JWT opens up more possibilities for microservices via public API.
It makes sense to implement token authorization at the API namespace level and provide an entry point for a React front-end through a static controller and view. Following this method, the client-side view logic can be handled by restricting private routes using Higher-Order Components (HOCs) and react-router to redirect to a login component if the user is not authorized. We'll look at how to integrate Devise—one of the most popular plug-n-play authentication libraries for Rails--and how to make it work with JSON Web Tokens (JWT).
What is JWT?
For a user to gain limited access to a web server, there needs to be an authentication scheme which allows the sharing of resources without revealing user credentials. With OAuth, this is referred to as flows. The HTTP authentication scheme typically uses a Bearer or Authentication token, which is a cryptic string generated by a server response upon login:
Authorization: Bearer <token>
The token is comprised of 3 distinct parts separated by dots: header.payload.signature. All of the parts are then Base64Url encoded for simple transmission in HTML and HTTP environments. The 3 parts are as follows:
1. Header
The first part of the JWT is the signing algorithm and type of token:
{ "alg": "HS256", "typ": "JWT" }
2. Payload
Second, is the payload which holds claims--pieces of information asserted about the subject:
{ "exp": "86400", "name": "Adam", "admin": true }
3. Signature
Last, is the signature created by signing the previously encoded parts using the algorithm specified in the header:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), 256-bit-secret )
The secret can be stored in rails credentials, and set in Devise's configuration:
config.jwt do |jwt| jwt.secret = Rails.application.credentials.dig(:devise_jwt_secret_key) end
The output will be a JSON Web Token signed with the secret key:
eyJhbGciOiJIUzI1NiJ9. eyJlbWFpbCI6ImFkYW1uYWFtYW5pQGdtYWlsLmNvbSIsImFkbWluIjp0cnVlLCJzdWIiOiIxIiwic2NwIjoidXNlciIsImF1ZCI6bnVsbCwiaWF0IjoxNTkxMTI4MTg5LCJleHAiOjE1OTEyMTQ1ODksImp0aSI6IjFhNGVhNDE1LWYxNTktNDhkOC05ZjlhLTQzYzNhMjZhZTlhOCJ9. XutPAVM0Lxoyx_qv9QefVtgmtFKcVEg0N-tERlZneKU
Enter Devise
Devise is based on Warden--a Rack-based authentication middleware for Ruby--offering a complete MVC solution based on Rails engines, and powerful options for authenticating users. Install the following gems in your Gemfile:
gem 'devise' gem 'devise-jwt' gem 'pundit' gem 'js-routes'
Managing simple http requests are fairly straight-forward and as simple as using Devise's built-in generators:
$ rails generate devise:install $ rails generate devise User $ rails generate devise:views users $ rails db:migrate
You would then add a before_action callback wherever you want protected routes:
before_action :authenticate_user!
One of the biggest snags I ran into was originally placing this method in the ApplicationController, so I moved it to a base controller that all API controllers inherit from. Doing so offloaded all authentication responsibility to the client, and prevented redirect hell. Next, configure the User model to include the strategy:
class User < ApplicationRecord devise :database_authenticatable, :registerable, :validatable, :trackable, :rememberable, :recoverable, :timeoutable, :lockable, :invitable, :omniauthable, :jwt_authenticatable, jwt_revocation_strategy: JwtBlacklist, omniauth_providers: %i[google_oauth2] def self.from_omniauth(auth) where(email: auth.info.email) .or(User.where(uid: auth.uid)) .first_or_initialize end def jwt_payload { email: email, admin: admin } end def generate_jwt JWT.encode({ id: id, exp: 1.day.from_now.to_i }, DEVISE_SECRET_KEY) end def on_jwt_dispatch(_token, _payload) JwtBlacklist.where('exp < ?', Date.today).destroy_all end end
Devise makes helpers available that pair well with Pundit, so you can not only authorize access to the app but restrict what users can access within the app:
user_signed_in? current_user user_session
There are a plethora of resources online that will delve into the specific configurations for Devise, but these were the ones that gave me the most trouble when not configured correctly:
config.skip_session_storage = %i[http_auth] config.jwt do |jwt| jwt.secret = Rails.application.credentials.dig(:devise_jwt_secret_key) jwt.expiration_time = 1.day.to_i jwt.request_formats = { user: [:json] } jwt.dispatch_requests = [ ['POST', %r{^/api/signup$}], ['POST', %r{^/api/login$}] ] jwt.revocation_requests = [ ['DELETE', %r{^/api/signout$}] ] end
This basically instructs Devise to dispatch a JWT when an authorized request is made through one of these endpoints. You also need to specify the routes for Devise:
Rails.application.routes.draw do defaults format: :json do namespace :api do devise_for :users, skip: %i[sessions registrations] devise_scope :user do post 'signup', to: 'registrations#create' post 'login', to: 'sessions#create' end end end end
Setting Up the Client-Side
Now that the back-end is configured to issue tokens, we can set up the front-end. Use axios' interceptors to set the defaults on every request/response:
import axios from 'axios' import { setToken, getToken, removeToken } from '../auth/token' axios.defaults.headers.common.Accept = 'application/json' axios.defaults.headers.common['Content-Type'] = 'application/json' axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' axios.defaults.responseType = 'json' axios.defaults.withCredentials = true axios.interceptors.request.use((request) => { if (getToken()) { axios.defaults.headers.common.Authorization = `Bearer ${token}` } return request }, (error) => { handleError(error) }) axios.interceptors.response.use((response) => { const { authorization } = response.headers if (authorization) { removeToken() setToken(authorization.split(' ')[1]) } return response }, (error) => { handleError(error) })
When a request is made from the client to the api/login endpoint, the Bearer token will be returned in the response.
axios.post(Routes.api_login(), formData) .then((response) => response)
If you are using React as your front-end framework, create a separate Authentication component apart from the views generated by Devise. The routing logic is handled with a react-router-dom Higher-Order Component, which checks if the user is authenticated from the aforementioned server response, and injects the appropriate component.
import React from 'react' import { Route, Redirect } from 'react-router-dom' import { isAuthenticated } from '../../api/index' const PrivateRoute = ({ component: Component, ...rest }) => ( <Route {...rest} render={(props) => (isAuthenticated() ? ( <Component {...props} /> ) : ( <Redirect to={{ pathname: '/login', state: { from: props.location } }} /> ))} /> ) export default PrivateRoute