Did I mentioned You should always use already-built solutions, especially for production apps. Building something from scratch is super insecure, and in my opinion, is an antipattern. If you want to keep credentials on your side there are devise or rodauth But you should consider keeping it outside the app with auth0 firebase auth or supabase Don’t do this yourself.

But this is the second part of building accounts-related stuff from scratch. In the previous post, I added the possibility to create new accounts in the system. Today I want to show you how to implement authentication.

I need new endpoint where I can login into my app

require_relative "controller"
module Auth
  class App
    def call(env)
      request = Rack::Request.new(env)

      case request.path_info
      when "/auth"
        case request.request_method
        when "POST"
          Controller.new.create(request)
        else
          [405, {"Content-Type" => "text/plain"}, ["Method Not Allowed"]]
        end
      else
        [404, {"Content-Type" => "text/plain"}, ["Not Found"]]
      end
    end
  end
end

In the controller I can verify if credentials are correct

account = Auth::Repository.new.find_by_email!(params["email"])
if account&.authenticate(params["password"])

Method authenticate if from ActiveRecord (I mentioned, ActiveRecord makes too much). But under the hood, it looks like that:

def authenticate(unencrypted_password)
  BCrypt::Password.new(password_digest).is_password?(unencrypted_password) && self
end

Ok, but I’m not going to send credentials with every request. I should use a token in header. So endpoint should also return a token. I’m going to use JSON Web Token (JWT). There is ruby gem for JWT. For this, I create a separate service.

module Auth
  class JsonWebToken
    def self.encode(payload, exp = 24.hours.from_now)
      payload[:exp] = exp.to_i
      JWT.encode(payload, ENV["SECRET_KEY"])
    end

    def self.decode(token)
      decoded = JWT.decode(token, ENV["SECRET_KEY"])[0]
      HashWithIndifferentAccess.new decoded
    end
  end
end

As you can see token will expire after 24 hours. I’m not going to implement logic for refreshing them after the token expired you have to fulfill credentials again. This is whole controller:

require_relative "../db/records/account"
require_relative "./json_web_token"
require_relative "./repository"
module Auth
  class Controller
    def create(request)
      params = JSON.parse(request.body.read)
      account = Auth::Repository.new.find_by_email!(params["email"])
      if account&.authenticate(params["password"])
        token = Auth::JsonWebToken.encode(account_id: account.id)
        time = Time.now + 86400
        response = {
          token: token,
          exp: time.strftime("%m-%d-%Y %H:%M"),
        }
        [201, {"content-type" => "text/plain"}, [response.to_json]]
      else
        [401, {"content-type" => "text/plain"}, ["Unauthorized"]]
      end
    rescue Auth::RecordNotFound
      [401, {"content-type" => "text/plain"}, ["Unauthorized"]]
    end
  end
end

I was thinking where is a good place to verify tokens. In the main app? In each app? Maybe in controllers. I decided to write middleware for rack. But if you think this isn’t a good place email me, please.

require "rack"
require_relative "auth/verify_and_set_account"
class AuthMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    req = Rack::Request.new(env)
    return @app.call(env) if req.path == "/"
    return @app.call(env) if req.path == "/environment"
    return @app.call(env) if req.path.start_with?("/auth")
    return @app.call(env) if req.path.start_with?("/accounts") && req.request_method == "POST"

    env["account_id"] = Auth::VerifyAndSetAccount.new.call(env)

    @app.call(env)
  end
end

First I ignore paths that are public, then I call my service, where I decode token, but also check if the user exists in DB. Then I return user_id and it passes in every request to the app. So information about user_id is available everywhere.

header = env["HTTP_AUTHORIZATION"]
header = header.split(' ').last if header
decoded = Auth::JsonWebToken.decode(header)
Auth::Repository.new.find(id: decoded["account_id"])
decoded["account_id"]

In the end, I have to add middleware in config.ru

require_relative "app"
require_relative "auth_middleware"
...
use AuthMiddleware
run MyApp.new

One of the cons (or pros depending on the point of view) of this solution is, nothing changes from the perspective of a test, we authorize users on requests, but nowhere in the app. Of course, I write spec for middleware. But this is not the same.

Since I don’t align frontend, I can check these changes with curl.

First I need to create a user:

curl -X POST http://localhost:9292/accounts -d '{"email":"foo@bar.ex","password":"qqrq"}'

Then login into app:

curl -X POST http://localhost:9292/auth -H 'Content-Type: application/json' -d '{"email":"foo@bar.ex","password":"qqrq"}'

The response should look like that:

{"token":"exampletoken","exp":"07-31-2023 17:21"}

And now I can check regular endpoints:

curl -X GET http://localhost:9292/bikes -H 'Authorization: exampletoken'

From now, If we try to use API without or with wrong token it should return Unauthorized error.

Like always, the whole code is on github