🎉 I'm releasing 12 products in 12 months! If you love product, checkout my new blog workingoutloud.dev

Back to home

Devise Part 4: Authentication With A Separate Frontend

In the previous post (part three), we added in Tailwind to our setup to improve our default Devise views.

This part will create a new Next.js project and add in (rudimentary) authentication.

In our case, we will be continuing on with the default authentication setup, although I may write another part towards the end that uses devise_auth_token which would work better for mobile apps, etc.

Source code can be found here.

Prerequisites

  1. Basic familiarity with setting up a new Rails project.
  2. Not entire required to follow along, but we will be using Next.js in this demo.

Getting started

We will be working from the source code found here.

# Clone and change into the project $ git clone https://github.com/okeeffed/demo-rails-7-with-devise-series $ cd demo-rails-7-with-devise-series # Continue from part 2 - warning that Redis required $ git checkout 3-adding-tailwind # Add the necessary gems $ bundler add rack-cors # Create a sessions controller to pass back an initial CSRF token $ bin/rails g controller session index # Add our file required for cors config $ touch config/initializers/cors.rb

We add in rack-cors as we will need to update our Rails API to allow cross-origin requests.

Setting up our Next.js project

From the root directory of the project, let's create a new project called next-frontend.

# Create a new Next.js application $ npx create-next-app@latest --ts next-frontend # Create required files $ mkdir next-frontend/lib $ touch next-frontend/pages/another.tsx next-frontend/lib/axios.ts # We'll use the axios package for sending requests $ npm --prefix next-frontend install axios

Update next-frontend/package.json to have the dev script start on port 4000 by updating it to next dev -p 4000".

Once that is done, we will update our bin/dev script to start Next.js at the same time:

web: bin/rails server -p 3000 css: bin/rails tailwindcss:watch next: npm --prefix next-frontend run dev

Note: The prefix flag enable us to run an npm script in another directory.

Updating our files in our Next.js project

I won't go into too many details about what is happening here - I am hoping that you understand Next.js.

The general gist of what we want to do is update the page at localhost:4000 to have a basic login form with a button to test an endpoint and see if we can authenticate.

We will also add a button to go to the localhost:4000/another route from that page.

To do so, update the code at next-frontend/pages/index.tsx to:

import type { NextPage } from "next"; import Head from "next/head"; import Link from "next/link"; import styles from "../styles/Home.module.css"; import axios from "../lib/axios"; import * as React from "react"; const Home: NextPage = () => { const onSubmit = async (e: React.SyntheticEvent) => { try { e.preventDefault(); console.log("submit"); const target = e.target as typeof e.target & { email: { value: string }; password: { value: string }; }; const { data } = await axios.post("http://localhost:3000/users/sign_in", { user: { email: target.email.value, password: target.password.value, remember_me: 0, }, }); window.alert("Sign in successful"); } catch (err) { window.alert("Sign in failed!"); } }; // This will always cause an error with this code, // but we will be signed out so ignore the error. const onSignOut = async (e: React.SyntheticEvent) => { e.preventDefault(); try { await axios.delete("http://localhost:3000/users/sign_out"); } catch (e) { window.alert("Signed out!"); } }; const testEndpoint = async () => { try { const { data } = await axios.post("http://localhost:3000/home"); window.alert(JSON.stringify(data)); } catch (e) { window.alert((e as Error).message); } }; return ( <div className={styles.container}> <Head> <title>Create Next App</title> <meta name="description" content="Generated by create next app" /> <link rel="icon" href="/favicon.ico" /> </Head> <main className={styles.main}> <form onSubmit={onSubmit}> <div> <input name="email" type="text" placeholder="Email" /> </div> <div> <input name="password" type="password" placeholder="Password" /> </div> <div> <button type="submit">Sign in</button> </div> </form> <button onClick={onSignOut}>Sign out</button> <button onClick={testEndpoint}>Test Endpoint</button> <Link href="/another">Go to /another</Link> </main> </div> ); }; export default Home;

Another is similar to home but without the login. It is just to test whether or not we understand that you session is still active.

import type { NextPage } from "next"; import Head from "next/head"; import Link from "next/link"; import styles from "../styles/Home.module.css"; import axios from "../lib/axios"; const Another: NextPage = () => { const testEndpoint = async () => { try { const { data } = await axios.post("http://localhost:3000/home"); window.alert(JSON.stringify(data)); } catch (e) { // please ignore my forced typecasting window.alert((e as Error).message); } }; return ( <div className={styles.container}> <Head> <title>Create Next App</title> <meta name="description" content="Generated by create next app" /> <link rel="icon" href="/favicon.ico" /> </Head> <main className={styles.main}> <button onClick={testEndpoint}>Test Endpoint</button> <Link href="/">Go to home page</Link> </main> </div> ); }; export default Another;

Passing the CSRF Token with our requests

In the above code, we are importing axios from next-frontend/lib/axios.ts.

We are yet to fill in that code. Add the following:

import axios from "axios"; axios.defaults.xsrfCookieName = "CSRF-TOKEN"; axios.defaults.xsrfHeaderName = "X-CSRF-Token"; axios.defaults.withCredentials = true; export default axios;

Here we are setting a default of axios to always pass our CSRF token under the head X-CSRF-Token header which grabs the value from the cookie we have stored under CSRF-TOKEN.

We now need to setup our Rails API to give us that CSRF token on each request.

Updating our Rails API

Back in our application controller, let's update our code to set the CSRF-TOKEN on each request:

class ApplicationController < ActionController::Base protect_from_forgery with: :exception before_action :set_csrf_cookie before_action :authenticate_user! private def set_csrf_cookie cookies['CSRF-TOKEN'] = form_authenticity_token end end

This will ensure that we can get the token from the cookie and pass it to our axios requests.

That being said, we already generated the controller earlier. Head to app/controllers/session_controller.rb and update the code to this:

class SessionController < ApplicationController skip_before_action :authenticate_user! def index; end end

We are essentially bypassing the authentication and just sending the CSRF token for this route.

To use the route, you will need to add resources :session, only: [:index] to our config/routes.rb file.

Updating our app to grab the initial CSRF token

We need to update Next.js to grab the initial token.

We can do so in this contrived solution by updating next-frontend/pages/_app.tsx:

import "../styles/globals.css"; import type { AppProps } from "next/app"; import { useEffect } from "react"; import { useRouter } from "next/router"; import axios from "../lib/axios"; function MyApp({ Component, pageProps }: AppProps) { const router = useRouter(); useEffect(() => { axios.get("http://localhost:3000/session"); }, [router.asPath]); return <Component {...pageProps} />; } export default MyApp;

We are nearly there! The last thing that we need to do is configure our CORS policy.

Configuring CORS

We installed the rack-cors gem during the setup here, so we need to enable it to allow both localhost:3000 AND localhost:4000 to access our resources.

We can do so by updating config/initializers/cors.rb to this:

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

Turning off the forgery protection origin check

Lastly, we need to create an API route that we want to bypass the forgery protection for.

Since this is a contrived post and we are using it to test our CSRF token cross-origin, we can do so by updating our config/application.rb file to the following:

require_relative 'boot' require 'rails/all' # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. Bundler.require(*Rails.groups) module DemoRails7WithDeviseSeries class 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") # Since we will be using other origins config.action_controller.forgery_protection_origin_check = false end end

This will allow our Next.js application to make requests to our API from localhost:4000 if the CSRF token is present.

Updating our Home controller

Lastly, we set up our Next.js app to ping the POST endpoint localhost:3000/home.

Although this is a contrived example we are making, we need to update the home controller to respond to this request.

Add the create method to the home controller app/controllers/home_controller.rb:

class HomeController < ApplicationController def index; end def create render json: { message: 'Welcome to the API' } end end

Finally, update the routes config files config/routes.rb to this:

Rails.application.routes.draw do get 'session/index' devise_for :users # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html resources :session, only: [:index] resources :home, only: %i[index create] # Defines the root path route ("/") root 'home#index' end

Testing our CSRF token

We can start the Rails app and Next.js app by running bin/dev from the root in a terminal.

Open up our Next.js app at localhost:4000 and you will see the following:

Next.js home page

Next.js home page

Sign in, and a popup will let you know we've signed in:

Sign in success

Sign in success

Note: Because of the contrived-nature of the code, the endpoint will also return 200 on a failed sign-in but you can check this on the network settings or note a failure if the next part does not return as expected.

If we click out test endpoint button, we get the JSON that we expected back:

Expected response

Expected response

If we now selected "sign out" and hit our test endpoint button without signing in, we will get a 422 error.

422 error

422 error

In order to test the /another endpoint, you will need to refresh the page (bad design by me with the CSRF token being destroyed when we signed out). If you do follow to /another after successfully signing in, you'll note the API continues to work.

Summary

Today's post demonstrated how to set up CORS and CSRF protection in Rails with a remote Next.js frontend on another domain.

With some code (prone to errors), we were able to demonstrate how we can sign in and out of this remote app.

The example itself was quite contrived and it is worth going over it with a grain of salt: there are a lot of improvements to be made and potential risks to double-check.

There is an alternative that uses an opaque token with the devise_auth_token gem that I will demonstrate in another part.

Resources and further reading

Photo credit: kevinzk429

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on Visibuild.

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.

Devise Part 4: Authentication With A Separate Frontend

Introduction

Share this post