Devise Part 4: Authentication With A Separate Frontend
Published: Mar 7, 2022
Last updated: Mar 7, 2022
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.
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:
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
Sign in, and a popup will let you know we've signed in:
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
If we now selected "sign out" and hit our test endpoint button without signing in, we will get a 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.