Turbo Native Authentication Part 1 - Rails Backend

In this Turbo Native series, we’re going to build native auth. Part 1 will cover the server side. Part 2 will cover Turbo iOS, and then we’ll end with Android.

Authentication is complicated. Not because it’s hard but because it’s scary.

The Rails app is just an example of getting up and running, but you may need to adjust for your security needs if you wish to run it on production.

The source code can be found here, and it exists as a template so you can clone it into your app. With some tweaks, the app builds off the authentication-zero gem, so we have API authentication and ordinary sign-in.

Initial Confusion

One of the parts I found confusing about authentication was this line in the docs.

> For HEY, we have a slightly different approach. We get the cookies back along with our initial OAuth request and set those cookies directly to the web view and global cookie stores

This is not something I encountered before when using SPAs. When you are building a Rails backend with a SPA, I have only had experience with token-based authentication or cookie-based authentication. I have never done both.

With further ado, let’s dive in.

Rails Scaffold

First, we start with the traditional Rails scaffold.

rails new turbo_auth_rails --javascript=esbuild --css=tailwind --database=postgresql

Next, we add the gem authentication-zero to our Gemfile

Authentication Zero

bundle add authentication-zero

I then ran the following:

rails g authentication --pwned  --passwordless --omniauthable && rake db:migrate

For the next part, I wanted to implement token based authentication.

class Api::V1::SessionsController < Api::ApplicationController
  skip_before_action :authenticate, only: :create

  before_action :set_session, only: %i[ show destroy ]

  def index
    render json: Current.user.sessions.order(created_at: :desc)
  end

  def show
    render json: @session
  end

  def create
    user = User.find_by(email: params[:email])

    if user && user.authenticate(params[:password])
      @session = user.sessions.create!
      response.set_header "X-Session-Token", @session.signed_id
      cookies.signed.permanent[:session_token] = { value: @session.id, httponly: true }

      render json: @session, status: :created
    else
      render json: { error: "That email or password is incorrect" }, status: :unauthorized
    end
  end

  def destroy
    @session.destroy
  end

  private
  def set_session
    @session = Current.user.sessions.find(params[:id])
  end
end

The controller exists in app/controllers/api/v1/sessions_controller.rb. The corresponding routes look like this.

namespace :api do
  namespace :v1 do
    resources :sessions, only: [:index, :create, :show, :destroy] 
  end
end

There are no views since everything renders JSON.

We set the response header in the controller and assign the cookies with the session id.

 response.set_header "X-Session-Token", @session.signed_id
 cookies.signed.permanent[:session_token] = { value: @session.id, httponly: true }

I then modified the API application controller generated by authentication-zero to the following:

class Api::ApplicationController < ActionController::API
  include ActionController::HttpAuthentication::Token::ControllerMethods
  include ActionController::Cookies

  before_action :set_current_request_details
  before_action :authenticate

  private
  def authenticate
    if session_record = authenticate_with_http_token { |token, _| Session.find_signed(token) }
      Current.session = session_record
    else
      request_http_token_authentication
    end
  end

  def set_current_request_details
    Current.user_agent = request.user_agent
    Current.ip_address = request.ip
  end
end

I also modified the main application controller:

class ApplicationController < ActionController::Base
  before_action :set_current_request_details
  before_action :authenticate


  def authenticate
    if authenticate_with_token || authenticate_with_cookies
      # Great! You're in
    elsif !performed?
      request_api_authentication || request_cookie_authentication
    end
  end

  def authenticate_with_token
    if session = authenticate_with_http_token { |token, _| Session.find_signed(token) }
      Current.session = session
    end
  end

  def authenticate_with_cookies
    if session = Session.find_by_id(cookies.signed[:session_token])
      Current.session = session
    end
  end

  def user_signed_in?
    authenticate_with_cookies || authenticate_with_token
  end

  helper_method :user_signed_in?
  

  def request_api_authentication
    request_http_token_authentication if request.format.json?
  end

  def request_cookie_authentication
    session[:return_path] = request.fullpath
    render 'sessions/new', status: :unauthorized, notice: "You need to sign in"
  end

  def set_current_request_details
    Current.user_agent = request.user_agent
    Current.ip_address = request.ip
  end
end

This checks if we either have a token or a cookie. We also return a 401 status code if a user is not logged in or needs to be authenticated.

Posts Scaffold

Let’s now build a scaffold.

rails g scaffold Post title:string  content:rich_text && rails db:migrate

Not all styling is in the repo if you wish to copy and paste. You will need to copy the layout/application.html.erb file, the posts folder, and the sessions/new folder.

Finally, in our posts controller, we added the following:

class PostsController < ApplicationController
  before_action :authenticate, except: [:index, :show]

This will trigger a 401 status whenever a user tries to log in. This will be used to start a login on our Turbo iOS app in the next blog post.

Finally, let’s add a path_configuration route.

class Turbo::Ios::PathConfigurationsController < ApplicationController
  skip_before_action :authenticate

  def show
    render json: {
      rules: [
        {
          patterns: ["/new$", "/edit$"],
          properties: {
            presentation: "modal"
          }
        },
        {
          patterns: ["/sign_in$"],
          properties: {
            presentation: 'authentication'
          }
        }
      ]
    }
  end
end

Then we update our routes.rb file.

  namespace :turbo do
    namespace :ios do
      resource :path_configuration, only: [:show]
    end
  end

Learn to Build Android, iOS and Rails Apps using Hotwire

© 2024 William Kennedy, Inc. All rights reserved.