This app uses a Rails API and React frontend that can be deployed to a single domain.
The goal of this app is to build use Rails built-in session features to authenticate users making requests to the Rails API. The key requirement is:
- Frontend and backend must be on the same domain. Since most modern browsers
now enforce
SameSite
cookies, session cookies will only work for our app if it is on the same domain.
Optionally (since this adds a fair amount of complexity):
- The app should also use Rails CSRF tokens for protection against Cross-Site
Request Forgery attacks.
SameSite
cookies offer a good degree of protection against CSRF attacks, but as an extra layer of protection, we can also use CSRF tokens to safeguard browsers that aren't enforcingSameSite
cookies.
For ease of deployment, both projects are contained in the same repository. All
React code is in the /client
directory.
bundle install
rails db:create db:migrate db:seed
npm install --prefix client
Run this command:
rake start
To run React and Rails in development, this app uses the foreman
gem. Configuration for running in development is in the Procfile.dev
file. For
convenience, there is a Rake task in lib/tasks/start.rake
to run foreman
.
In development, requests from the React app are proxied, so you can write something like this (without using a domain):
fetch("/api/me").then((r) => r.json());
Since our deployed app will run on the same domain, this is a good way to simulate a similar environment in development. It also helps avoid CORS issues.
Install Heroku CLI (if you don't already have it):
brew tap heroku/brew && brew install heroku
Login:
heroku login
Create new Heroku app:
heroku apps:create
Add buildpacks for Heroku to run:
- Rails app on Ruby
- React app on Node
heroku buildpacks:add heroku/nodejs --index 1
heroku buildpacks:add heroku/ruby --index 2
Deploy:
git push heroku main
By default, when generating a new Rails app in API mode, the middleware for
cookies and sessions isn't included. We can add it back in (and specify the
SameSite
policy for our cookies for protection):
# config/application.rb
# Adding back cookies and session middleware
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore
# Use SameSite=Strict for all cookies to help protect against CSRF
config.action_dispatch.cookies_same_site_protection = :strict
We also need to include helpers for sessions/cookies in our controllers:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include ActionController::Cookies
end
Now, we can set a session cookie for users when they log in:
def create
user = User.find_by(username: params[:username]).authenticate(params[:password])
session[:user_id] = user.id
render json: user
end
You should include cookies when making any requests to the API. Here's how to
include cookies with fetch
:
fetch("/api/me", {
credentials: "include",
});
Or with axios
:
axios.get("/api/me", {
withCredentials: true,
});
// or, to enable for all requests:
// axios.defaults.withCredentials = true;
We'll deploy our frontend and backend to Heroku on one single app. There are a
couple key pieces to this configuration. First, the
Procfile
:
web: bundle exec rails s
release: bin/rake db:migrate
This gives Heroku instructions on commands to run on release (run our migrations), and web (run rails server).
Second, the package.json
file in the root directory (not the one in the
client directory):
{
"name": "session-auth-example",
"description": "Build scripts for Heroku",
"engines": {
"node": ">= 14"
},
"scripts": {
"build": "npm install --prefix client && npm run build --prefix client",
"clean": "rm -rf public",
"deploy": "cp -a client/build/. public/",
"heroku-postbuild": "npm run clean && npm run build && npm run deploy"
}
}
The heroku-postbuild
script will run when our app has
been deployed. This will build the production version of our React app.
For our deployed app, we need non-API requests to pass through to our React application. Otherwise, routes that would normally be handled by React Router will be handled by Rails instead.
Setup routes fallback:
# config/routes.rb
get '*path', to: "fallback#index", constraints: ->(req) { !req.xhr? && req.format.html? }
Add controller action:
class FallbackController < ActionController::Base
def index
render file: 'public/index.html'
end
end
Note: this controller must inherit from ActionController::Base
instead of
ApplicationController
, since ApplicationController
inherits from
ActionController::API
. API controllers can't render HTML. Plus, we don't need
any of the auth logic in this controller.