Tutorial Adding Authentication to Inertia Rails app

Updated to reflect changes in InertiaJS 1.0

In our previous tutorial, we learned how to create CRUD pages for an Inertia Rails app.
In today's tutorial will be adding authentication (i.e user login) to our new app.

If you have ever built a SPA app, then you will understand how easy it is to get tripped by auth. Luckily with Inertia, we don't have to worry about such thing as client side authentication - everything is taken care of on the server side.

Add Devise gem to Rails

Add Devise gem to the Gemfile

gem "devise"

Install the gem

bundle install

Run the Devise install script

rails generate devise:install

Follow the message to complete the setup:

In config/environments/development.rb, add

# for devise
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

The Devise gem is now ready to use. Let's create our user model.

rails g devise User

We will add a few fields to the user table, so we can also save user names and admin status.

/db/migrate/..._devise_create_users.rb

      # custom fields
      t.string :first_name
      t.string :last_name
      t.boolean :admin

Run the migration. Now we should have our user model ready.

rails db:migrate

Add auth logic to the Rails controller

We will create a Rails concern to handle our auth logic.

app/controller/concerns/auth.rb

require 'active_support/concern'

module Auth
  extend ActiveSupport::Concern

  included do
    before_action :authenticate_user!
  end

  private

  def after_sign_in_path_for(resource)
    stored_location_for(resource) || root_path
  end

  def after_sign_out_path_for(_resource_or_scope)
    new_user_session_path
  end
end

Then attach the concern to where we want to implement auth, which is articles controller.

app/controller/articles_controller.rb

class ArticlesController < ApplicationController
  include Auth
  ...
end

You may have noticed: the Auth module should be included in the application controller. But since we are not adding authorization yet, adding check on the one controller that needs auth for now is good enough.
We will add full authorization using Pundit in our next tutorial.

And... Done!

Go to the dev app and click on the Articles link, we should see a message appear asking us to login.

Now is time to add the login page.

Update the sessions controller and routes

But before we can add the login page, we must tell Devise we are using components, otherwise Devise will redirect to its HTML templates.

In Devise, a login page is a new_user_session page. So we will first customize the sessions controller.

Create Devise sessions controller with this command:

rails g devise:controllers users -c=sessions

Notice the -c=sessions option, this tells the generator to only create sessions controller.

We will then update the sessions controller with our own routes:

app/controller/users/sessions_controller.rb

class Users::SessionsController < Devise::SessionsController
  # GET /login
  def new
    render inertia: 'Auth/Login', props: {}
  end
end

Notice we tell the controller our login page is an Inertia component located at Auth/Login. We will create this component later.

We will also update our route to reflect the paths we have chosen.

config/routes.rb

  # replace this generated route:
  # devise_for :users
  # with the new route below:

  # devise routes
  devise_for :users, skip: [ :sessions, :passwords, :registrations ]
  as :user do
    get 'login', to: 'users/sessions#new', as: :new_user_session
    post 'login', to: 'users/sessions#create', as: :user_session
    match 'logout', to: 'users/sessions#destroy', as: :destroy_user_session, via: Devise.mappings[:user].sign_out_via
  end

And that's it for the Rails side.
If you have ever setup Devise for Rails before, you will notice there aren't many differences.
Onward to the frontend side!

Add the login page

As promised before in the sessions controller, we will create our login page as Auth/Login.

tut-7.png

frontend/pages/Auth/Login.svelte

<svelte:head>
  <title>Login</title>
</svelte:head>

<script>
  import { useForm } from '@inertiajs/svelte'

  let form = useForm({
    user: {
      email: null,
      password: null,
      remember: false,
    }
  })

  function submit() {
    $form.post('/login')
  }
</script>

<form on:submit|preventDefault={submit}>
  <input type="text" bind:value={$form.user.email} />
  {#if $form.errors.email}
    <div class="form-error">{$form.errors.email}</div>
  {/if}
  <input type="text" bind:value={$form.user.password} />
  {#if $form.errors.password}
    <div class="form-error">{$form.errors.password}</div>
  {/if}
  <input type="checkbox" bind:checked={$form.user.remember} /> Remember Me
  <button type="submit" disabled={$form.processing}>Login</button>
</form>

Notice the <svelte:head> tag is just to tell Svelte to update the page title when this component is mounted.

One more detail to attend to

Remember we attached CSRF token to our axios requests? That was a quick way to provide CSRF token, but with our app growing, we will need a better way to handle it.

We can use a Rails concern to address this.
app/controller/concerns/inertia_csrf.rb

require 'active_support/concern'

# Store the CSRF token in a non-session cookie so Axios can access it
# Name it as XSRF-TOKEN, because this is the Axios default
#
# More info: https://pragmaticstudio.com/tutorials/rails-session-cookies-for-api-authentication
#
module InertiaCsrf
  extend ActiveSupport::Concern

  included do
    before_action :set_csrf_cookie

    rescue_from ActionController::InvalidAuthenticityToken do
      redirect_back fallback_location: '/', notice: 'The page expired, please try again.'
    end
  end

  # Rails uses HTTP_X_CSRF_TOKEN, but axios sends HTTP_X_XSRF_TOKEN (different name, X instead of C)
  # By overriding `request_authenticity_tokens` we can tell Rails to check HTTP_X_XSRF_TOKEN, too
  # Source: https://github.com/rails/rails/blob/v6.0.3.2/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L305-L308
  def request_authenticity_tokens
    super << request.headers['HTTP_X_XSRF_TOKEN']
  end

  private

  def set_csrf_cookie
    cookies['XSRF-TOKEN'] = {
      value: form_authenticity_token,
      same_site: 'Strict'
    }
  end
end

Include this concern in application controller.
app/controller/application_controller.rb

class ApplicationController < ActionController::Base
  include InertiaCsrf
end

Then remove the previous code in our entrypoint file.

frontend/entrypoints/application.js

// replaced by InertiaCsrf.rb
// import axios from 'axios'
// const csrfToken = document.querySelector('meta[name=csrf-token]').content
// axios.defaults.headers.common['X-CSRF-Token'] = csrfToken

Now we will never have to worry about CSRF token again!

Test our login page

Create a test user

rails c
User.create! email: 'test@users.com', password: 'foobar', password_confirmation: 'foobar'

We will first pass our login status using Inertia's shared object.
app/controller/application_controller.rb

class ApplicationController < ActionController::Base
  include InertiaCsrf

  inertia_share auth: -> {
    if user_signed_in?
      {
        user: current_user.email
      }
    end
  }
end

What we did above is to check the user_signed_in? status from Devise, then if the user is signed in, pass the current_user email to the client.

We can then access these objects on the client side by calling $page.props, which is what we are going to do next.

Add login page link to our main navigation:
frontend/components/Layouts/MainNav.svelte

<script>
  import { Link, page, inertia } from '@inertiajs/svelte'
</script>

<nav class="max-w-7xl mx-auto p-6 flex justify-between">
  <ul class="flex items-center space-x-8">
    <li>
      <Link href="/">Home</Link>
    </li>
    <li>
      <Link href="/articles">Articles</Link>
    </li>
    <li>
      <Link href="/about">About</Link>
    </li>
  </ul>

  <div>
    {#if $page.props.auth}
      <button use:inertia="{{ href: '/logout', method: 'delete' }}"
              type="button">
        Sign out
      </button>
    {:else}
      <Link href="/login">Sign in</Link>
    {/if}
  </div>
</nav>

Notice how we can access the login status by checking $page.props.auth.

And that's all! You should have a working sign in / sign out now!

A quick note: if you found when you submitted wrong login information, and a popup appears - don't worry about it for now. We will cover that issue in the following authorization tutorial by adding flash messages.

A final retouch

Usually, we will want to allow all users to be able to view our articles, even when not logged in. We can achieve this very easily with just a few changes to our code.

First, we want to lift the login check on articles controller for only index and show actions.
app/controller/articles_controller.rb

class ArticlesController < ApplicationController
  include Auth

  before_action :set_article, only: %i[ show edit update destroy ]
  skip_before_action :authenticate_user!, only: %i[ index show ]

If you check the dev app, you should be able to see the articles index and show page can be accessed without login. Now we just need to hide the "admin only" parts of the page, which is very easy to do with Inertia and Svelte.

Can you figure out the missing part in the page below?
frontend/pages/Articles/Index.svelte

<script>
  import New from './New.svelte'
  import { inertia, page, Link } from '@inertiajs/inertia-svelte'

  let admin = $page.props.auth
  let show = false

  function toggleShow() {
    show = !show
  }

  export let articles
</script>

<h1 class="font-bold text-2xl mb-6">All Articles</h1>

{#if FILL_IN}
  <button on:click={toggleShow} >New Article</button>
  {#if show}
    <New/>
  {/if}
{/if}

<div class="mt-6">
  {#each articles as article}
    <div>
      <Link href="/articles/{article.id}">{article.title}</Link>
      {#if FILL_IN}
      <Link href="/articles/{article.id}/edit">Edit</Link>
      <button use:inertia="{{ href: '/articles/'+article.id, method: 'delete' }}" type="button" class="text-indigo-600 hover:text-indigo-900">Delete</button>
      {/if}
    </div>
  {/each}
</div>

In case you need it, you can find the full code in this repository: https://github.com/planetaska/irvst-app2/tree/authentication

And that wraps up the tutorial for today.
I hope this helps someone. Thank you for reading!