Rails 5 API and React frontend (JWT) token authentication

In this post I’ll share some code that demonstrates JWT authentication between a Rails API backend (using the Knock gem) and a React frontend. For this example I am using a Pages controller that has a public index route to list all the pages. The Page model will have a boolean attribute (allow_unauth) which determines if the unauthenticated user has access. The show method on the controller will check the user’s access and return its content if allowed. From the React frontend, there is a main component that handles the cookies, fetches the list of pages, and displays navigation items. I used React-Bootstrap for the markup, axios to make API calls, and generator-react-webpack to scaffold the frontend with webpack.

Part 1: Rails API

Scaffold a new Rails API project

mkdir -p api/rails-react-token-auth
cd api/rails-react-token-auth

# create RVM files
echo ruby-2.4.3 > .ruby-version
echo rails-react-token-auth > .ruby-gemset
rvm use .

# add rails
gem install rails
# defaulting to sqlite for this example
rails new . --api
# setup database
rake db:migrate

Knock/JWT integration with User model and controller:

Edit Gemfile, add: gem 'knock'.

Execute bundle install to install and dependencies.

Execute rails generate knock:install generator to add knock configuration.

Update application controller, edit file: app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  include Knock::Authenticable
end

Created migration to add User table

class CreateUsers < ActiveRecord::Migration[5.1]
  def change
    create_table :users do |t|
      t.string :email
      t.string :password_digest

      t.timestamps
    end

    add_index :users, %i(email), unique: true
  end
end

Added User model with basic validation, new file: app/models/user.rb

class User < ApplicationRecord
  has_secure_password

  validates :email, :password, presence: true
  validates :email, uniqueness: true
  validates :password, length: { minimum: 8 }
end

Add user token controller, new file: app/controllers/api/user_token_controller.rb, contents:

class Api::UserTokenController < Knock::AuthTokenController
end

Add controller to fetch current user, new file: app/controllers/api/users_controller.rb

class Api::UsersController < ApplicationController
  before_action :authenticate_user

  def current
    render json: current_user.as_json(only: %i(id email))
  end
end

Add controller routes, edit file: config/routes.rb

Rails.application.routes.draw do
  namespace :api do
    post 'user/token' => 'user_token#create'
    get 'users/current' => 'users#current'
  end
end

Next I added the Pages model and controller:

Rails migration to add Pages table:

class CreatePages < ActiveRecord::Migration[5.1]
  def change
    create_table :pages do |t|
      t.string :title
      t.text :content
      t.boolean :allow_unauth

      t.timestamps
    end

    add_index :pages, %i(title), unique: true
  end
end

Added Page model, new file: app/models/page.rb

class Page < ApplicationRecord
  validates :title, :content, presence: true
  validates :title, uniqueness: true
end

Created Pages controller, new file: app/controllers/api/pages_controller.rb

class Api::PagesController < ApplicationController
  before_action :set_page, only: %i(show)
  before_action :authenticate_user, only: %i(show), if: :page_access

  def index
    render json: Page.select(:id, :title, :allow_unauth)
  end

  def show
    render json: @page.as_json(only: %i(id title content allow_unauth))
  end

  private

  def set_page
    @page = Page.find(params[:id])
  end

  def page_access
    !@page.allow_unauth
  end
end

Updated routes for pages controller, edit file: config/routes.rb

Rails.application.routes.draw do
  namespace :api do
    post 'user/token' => 'user_token#create'
    get 'users/current' => 'users#current'
    resources :pages, only: %i(index show)
  end
end

Added seeds to populate public/private pages and a sample user, edit file: db/seeds.rb

(1..2).each do |i|
  Page.create!(title: "Public Page #{i}", content: "Public content #{i}", allow_unauth: true) rescue nil
  Page.create!(title: "Private Page #{i}", content: "Super secret content #{i}", allow_unauth: false) rescue nil
end

User.create!(email: 'eric.london@example.com', password: 'password')

Executed rake db:migrate to execute migrations, and rake db:seed to populate seeds data.

Last I setup the rack-cors gem to allow the frontend to make API calls.

Edit file Gemfile, added: gem 'rack-cors'.

Execute bundle install to install gem dependencies.

Add basic CORS initializer configuration, edit file: config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins ['localhost:8000']
    resource '*',
      headers: :any,
      methods: %i(get post put patch delete options head)
  end
end

Started the Rails API via: rails s, on default port 3000.

Part 2: React frontend

Scaffold the frontend project:

mkdir frontend && cd frontend

# setup NVM
echo v8.10.0 > .nvmrc
nvm use .

# install generator
npm install -g yo
npm install -g generator-react-webpack

# create project
yo react-webpack

# add additional npm packages
npm install --save axios
npm install --save react-cookie
npm install --save react-bootstrap
npm install --save react-router-dom
npm install --save react-router-bootstrap

I created an Api.js include to handle all API calls to Rails using axios, new file: src/lib/Api.js

var axios = require('axios')

let apiHost = 'http://' + (process.env.API_HOST || 'localhost') + ':3000'

module.exports = {
  authenticateUser: function(email, password) {
    let data = {
      auth: {
        email: email,
        password: password
      }
    }
    return axios.post(apiHost + '/api/user/token', data)
      .then(function (response) {
        return response.data.jwt
      })
      .catch(function (error) {
        return undefined
      })
  },
  getCurrentUser: function(jwt) {
    var config = {
      headers: {}
    }
    if (jwt) {
      config['headers']['Authorization'] = 'Bearer ' + jwt
    }
    return axios.get(apiHost + '/api/users/current', config)
      .then(function(response){
        return response.data
      })
      .catch(function (error) {
        return undefined
      })
  },
  getPages: function() {
    return axios.get(apiHost + '/api/pages')
      .then(function(response){
        return response.data
      })
      .catch(function (error) {
        return undefined
      })
  },
  getPage: function(jwt, id) {
    var config = {
      headers: {}
    }
    if (jwt) {
      config['headers']['Authorization'] = 'Bearer ' + jwt
    }
    return axios.get(apiHost + '/api/pages/' + id, config)
      .then(function(response){
        return response.data
      })
      .catch(function (error) {
        return undefined
      })
  }
}

Revised the Main.js component to integrate with a CookieProvider, edit file: src/components/Main.js

import React from 'react'
import { CookiesProvider } from 'react-cookie'
import TokenAuth from 'components/TokenAuth.js'

class AppComponent extends React.Component {
  render() {
    return (
      <CookiesProvider>
        <TokenAuth />
      </CookiesProvider>
    )
  }
}

AppComponent.defaultProps = {}

export default AppComponent

The main component adds a single TokenAuth component. This component handles the following:

  • cookie management
  • controlling a global application state
  • fetching Pages and the current User from the API
  • implementing dynamic routes based on if a user is logged in or not
  • handling propagation of authentication sign in/out responses
  • displaying a navigation bar (AppHeader)

new file: src/components/TokenAuth.js

import React from 'react'
import { instanceOf } from 'prop-types'
import { withCookies, Cookies } from 'react-cookie'
import { BrowserRouter as Router, Route } from 'react-router-dom'

import AppHeader from './AppHeader.js'
import AuthSignIn from './AuthSignIn.js'
import AuthSignOut from './AuthSignOut.js'
import PageHome from './PageHome.js'
import Page from './Page.js'

const Api = require('../lib/Api.js')

class TokenAuthComponent extends React.Component {

  static propTypes = {
    cookies: instanceOf(Cookies).isRequired
  }

  render() {
    return (
      <Router>
        <div>

          <AppHeader appState={this.state} />

          <Route exact path="/" component={PageHome} />

          <Route
            exact path='/page/:id'
            render={(routeProps) => (
              <Page {...routeProps} appState={this.state} />
            )}
          />

          {!this.state.jwt &&
            <Route
              exact path="/sign-in"
              render={(routeProps) => (
                <AuthSignIn {...routeProps} propagateSignIn={this.propagateSignIn} />
              )}
            />
          }

          {this.state.jwt &&
            <Route
              exact path="/sign-out"
              render={(routeProps) => (
                <AuthSignOut {...routeProps} propagateSignOut={this.propagateSignOut} />
              )}
            />
          }

        </div>
      </Router>
    )
  }

  componentDidMount() {
    this.getUser()
    this.getPages()
  }

  defaultState() {
    return {
      cookieName: 'rails-react-token-auth-jwt',
      email: undefined,
      jwt: undefined,
      user_id: undefined,
      pages: []
    }
  }

  constructor(props) {
    super(props)

    this.state = this.defaultState()

    this.propagateSignIn = this.propagateSignIn.bind(this)
    this.propagateSignOut = this.propagateSignOut.bind(this)
  }

  propagateSignIn(jwt, history = undefined) {
    const { cookies } = this.props
    cookies.set(this.state.cookieName, jwt, { path: '/' })
    this.getUser(history)
  }

  propagateSignOut(history = undefined) {
    const { cookies } = this.props
    cookies.remove(this.state.cookieName)
    this.setState({
      email: undefined,
      user_id: undefined,
      jwt: undefined
    })
    if (history) history.push('/')
  }

  getPages() {
    Api.getPages().then(response => {
      this.setState({
        pages: response
      })
    })
  }

  getUser(history = undefined) {
    const { cookies } = this.props
    let jwt = cookies.get(this.state.cookieName)
    if (!jwt) return null

    Api.getCurrentUser(jwt).then(response => {
      if (response !== undefined) {
        this.setState({
          email: response.email,
          user_id: response.id,
          jwt: jwt
        })
        if (history) history.push('/')
      }
      else {
        // user has cookie but cannot load current user
        cookies.remove(this.state.cookieName)
        this.setState({
          email: undefined,
          user_id: undefined,
          jwt: undefined
        })
      }
    })
  }

}

export default withCookies(TokenAuthComponent)

Here is the template I used for a basic page layout, ex: PageHome, new file: src/components/PageHome.js

import React from 'react'
import { Grid, Row, Col } from 'react-bootstrap'

class PageHomeComponent extends React.Component {

  render() {
    return (
      <Grid>
        <Row>
          <Col xs={12} md={12}>
            Home
          </Col>
        </Row>
      </Grid>
    )
  }

  constructor(props) {
    super(props)
  }

}

export default PageHomeComponent

Contents of the NavBar component which adds NavItem links for Home, SignIn, SignOut, and each Page conditionally, new file: src/components/AppHeader.js

import React from 'react'
import { Navbar, Nav, NavItem } from 'react-bootstrap'
import { LinkContainer } from 'react-router-bootstrap'

class AppHeaderComponent extends React.Component {

  render() {
    return (
      <Navbar inverse collapseOnSelect>
        <Navbar.Header>
          <Navbar.Brand>
            Rails React Token Auth
          </Navbar.Brand>
          <Navbar.Toggle />
        </Navbar.Header>
        <Navbar.Collapse>
          <Nav>
            <LinkContainer exact to="/">
              <NavItem eventKey={1}>
                Home
              </NavItem>
            </LinkContainer>

            {this.props.appState.pages.map(page =>
              <LinkContainer key={'page_' + page.id} exact to={'/page/' + page.id}>
                <NavItem eventKey={'2.' + page.id}>
                  {page.title}
                </NavItem>
              </LinkContainer>
            )}
          </Nav>
          <Nav pullRight>
            {!this.props.appState.jwt &&
              <LinkContainer exact to="/sign-in">
                <NavItem eventKey={3}>
                  Sign In
                </NavItem>
              </LinkContainer>
            }

            {this.props.appState.jwt &&
              <LinkContainer exact to="/sign-out">
                <NavItem eventKey={4}>
                  Sign Out
                </NavItem>
              </LinkContainer>
            }
          </Nav>
        </Navbar.Collapse>
      </Navbar>
    )
  }

  constructor(props) {
    super(props)
  }

}

export default AppHeaderComponent

The AuthSignIn component provides the user with a sign in form with basic error handling. On submit an API call is made to the UserToken controller, and on success the API returns a JWT (string). The JWT is propagated to the TokenAuthComponent, set in a cookie, and the current user is fetched from the API. The user’s email, id, and the JWT are stored in the TokenAuthComponent state. The JWT is passed to child components (as a prop) and used in subsequent API calls. new file: src/components/AuthSignIn.js

import React from 'react'
import { Grid, Row, Col, FormGroup, FormControl, ControlLabel, Button, Alert } from 'react-bootstrap'

const Api = require('../lib/Api.js')

class AuthSignInComponent extends React.Component {

  render() {
    return (
      <Grid>
        <Row>
          <Col xs={12} md={12}>

            {this.getFormErrors().length > 0 && this.state.formSubmitted &&
              <Alert bsStyle="danger">
                <strong>Please correct the following errors:</strong>
                <ul>
                {
                  this.getFormErrors().map((message,index) =>
                    <li key={'error_message_'+index}>{message}</li>
                  )
                }
                </ul>
              </Alert>
            }

            <form onSubmit={this.handleSubmit}>
              <FormGroup>
                <ControlLabel>Email</ControlLabel>
                <FormControl
                  id="authEmail"
                  type="email"
                  label="Email address"
                  placeholder="Enter email"
                  onChange={this.setEmail}
                />
              </FormGroup>

              <FormGroup>
                <ControlLabel>Password</ControlLabel>
                <FormControl
                  id="authPassword"
                  type="password"
                  label="Password"
                  placeholder="Enter password"
                  onChange={this.setPassword}
                />
              </FormGroup>

              <Button type="submit">
                Log in
              </Button>

            </form>
          </Col>
        </Row>
      </Grid>
    )
  }

  defaultState() {
    return {
      email: {
        value: '',
        error: 'Email is required.'
      },
      password: {
        value: '',
        error: 'Password is required.'
      },
      submit: {
        error: ''
      },
      formSubmitted: false
    }
  }

  constructor(props) {
    super(props)

    this.state = this.defaultState()

    this.handleSubmit = this.handleSubmit.bind(this)
    this.setPassword = this.setPassword.bind(this)
    this.setEmail = this.setEmail.bind(this)
  }

  getFormErrors() {
    let fields = ['email', 'password', 'submit']
    let errors = []
    fields.map(field => {
      let fieldError = this.state[field].error || ''
      if (fieldError.length > 0) {
        errors.push(fieldError)
      }
    })
    return errors
  }

  setEmail(event) {
    let newVal = event.target.value || ''
    let errorMessage = newVal.length === 0 ? 'Email is required.' : ''
    this.setState({
      email: {
        value: newVal,
        error: errorMessage
      },
      submit: {
        error: ''
      }
    })
  }

  setPassword(event) {
    let newVal = event.target.value || ''
    let errorMessage = newVal.length === 0 ? 'Password is required.' : ''
    this.setState({
      password: {
        value: newVal,
        error: errorMessage
      },
      submit: {
        error: ''
      }
    })
  }

  handleSubmit(event) {
    event.preventDefault()
    this.setState({
      formSubmitted: true,
      submit: {
        error: ''
      }
    })

    if (this.getFormErrors().length > 0) {
      return false
    }

    Api.authenticateUser(this.state.email.value, this.state.password.value).then(jwt => {
      if (jwt) {
        this.props.propagateSignIn(jwt, this.props.history)
      }
      else {
        this.setState({
          submit: {
            error: 'Sorry, we could not log you in with the credentials provided. Please try again.'
          }
        })
      }
    })
  }
}

export default AuthSignInComponent

I provided the route and AuthSignOut component to allow a user to sign out. On controller instantiation, it simply uses the propagate callback on the TokenAuthComponent to remove the cookie and clear the user attributes from state. new file: src/components/AuthSignOut.js

import React from 'react'

class AuthSignOutComponent extends React.Component {
  render() {
    return null
  }

  constructor(props) {
    super(props)
    this.props.propagateSignOut(this.props.history)
  }
}

export default AuthSignOutComponent

The final Page component handles loading and displaying the page content. The AppHeader component provides the nav item for each page, and the route with id param (/page/:id) is defined in TokenAuthComponent. When the Page component is mounted, it attempts to fetch the page content from the API using the JWT header. A ‘access denied’ flash message is displayed instead of the content when the API call fails. new file: src/components/Page.js

import React from 'react'
import { Grid, Row, Col, Alert } from 'react-bootstrap'

const Api = require('../lib/Api.js')

class PageComponent extends React.Component {

  render() {
    if (this.state.loading) {
      return null
    }

    return (
      <Grid>
        <Row>
          <Col xs={12} md={12}>

            {this.state.flashMessage.message &&
              <Grid>
                <Row>
                  <Col xs={12} md={12}>
                    <Alert bsStyle={this.state.flashMessage.style}>
                      {this.state.flashMessage.message}
                    </Alert>
                  </Col>
                </Row>
              </Grid>
            }

            <div>{this.state.page.content}</div>

          </Col>
        </Row>
      </Grid>
    )
  }

  componentDidMount() {
    this.getPage()
  }

  componentWillReceiveProps(nextProps) {

    let prevPageId = this.props.match.params.id
    let newPageId = nextProps.match.params.id

    // check if page component is being reloaded with new page props && reload page from Api
    if (prevPageId !== newPageId) {
      this.setState({
        page: {
          id: newPageId,
          content: ''
        }
      })
      this.getPage(newPageId)
    }
  }

  getPage(pageId = null) {
    pageId = pageId || this.state.page.id

    this.setState({
      loading: true,
      flashMessage: {
        message: undefined,
        style: 'success'
      }
    })

    let jwt = this.props.appState.jwt
    Api.getPage(jwt, pageId).then(response => {
      if (response) {
        this.setState({
          page: response,
          loading: false
        })
      }
      else {
        this.setState({
          loading: false,
          flashMessage: {
            message: 'Access Denied.',
            style: 'danger'
          }
        })
      }
    })
  }

  constructor(props) {
    super(props)

    this.state = {
      page: {
        id: props.match.params.id,
        content: ''
      },
      loading: true,
      flashMessage: {
        message: undefined,
        style: 'success'
      }
    }

  }

}

export default PageComponent

I started the app via: npm start, and browsed to http://localhost:8000/ to demo:

Rails React Token Authentication

Source code on Github

Updated: