User authentication with supertokens and ruby on rails

In the olden days, user-authentication was easy. You added a users table to your database, with username and password fields. People would register with usernames like mikeiscool1988 and sallysucksdicksXoXo and passwords like letmein and password and everyone was happy.

Then, as more and more of our servers got cracked and our databases pilfered, we realised it was a really bad idea storing our passwords in plain text. So we started hashing and salting those password fields. But it still wasn't enough as the bad guys had access to rainbow tables and ever increasing computing power.

And, to add to the pile, federated identity providers came along, so that users table alone just wasn't enough.

Now we're entering the era of passkeys, with biometrics and secure-device authentication, which is a whole new way of handling authentication.

The users table is starting to look pretty sorry for itself.

Introducing supertokens

SuperTokens is an open-source authentication system. An open-source project with a business model, so it's likely to have some longevity (not just because of the money but because being paid instead of just working in your spare time reduces the risk of contributor burnout).

It comes in three parts - the front-end, the back-end and the core. SuperTokens make their money from renting you servers to hold your core (and its database), but if you want, you can just download their docker image and set it all up yourself.

The core is the important bit. It's a database and API that keeps all your user's secrets locked away from prying eyes.

The back-end is a set of libraries that you can install into your app-server code. It talks to the core, verifying user sessions are valid and making them available to your app.

And finally, there's the front-end - some javascript libraries and an optional React component that control the authentication mechanisms that your users interact with.

So far, so good.

I love how it works, I love how it's structured and I love that there's a pre-built login component to save me time.

The problem

Just one problem.

The back-end - the bit that you install into your own code - is Go, Python and Javascript only. There's no Ruby library.

Of course, as it's open source, I could write my own. I thought about it, but after a (very short) conversation with the author, I realised it was quite a lot of work.

However there is a way to get it working with Rails. It's not perfect and I've not tested it across multiple machines yet, let alone in a staging or production environment. But so far it's doing the job as I build my new system.

The solution

First of all, add the SuperTokens javascript to your app, using whatever mechanism you prefer. For this app I'm using import-maps so it's pretty simple.

If you want to use the pre-built React component, you need to install React as well (which is why that was one of the examples in my import-maps post the other week). Create a route called /auth that shows a page with the React component installed and add a secondary route for the callback.

resource :auth, controller: "authentication" 
get "/auth/callback/:provider", to: "authentication#show"Copy

The authentication controller is below - for now the important bit is the show action, which just renders a ViewComponent that in turn renders the React component:

class AuthenticationController < ApplicationController
  include Jwt
  protect_from_forgery except: %i[update destroy]

  def show
    render App::LoginPanelComponent.new, content_type: "text/html"
  end

  def update
    reference = get_reference_from_jwt(params[:jwt])
    if reference.present?
      identity = Identity.where(reference: reference).first_or_create!
      session[:identity_id] = identity.id
    end

    head :ok
  end

  def destroy
    session[:identity_id] = nil
    head :ok
  end
end

The App::LoginPanelComponent renders some really simple HTML that triggers a related Stimulus controller:

<%= layout "cover", **props do %>
  <%= article do %>
    <%= div data: { controller: "app--login-panel-component" } %>
  <% end %>
<% end %>

Note, I've written helper methods to output the standard HTML tags - however, I'll soon be switching to Markaby. Which is why I spent some time contributing and adding a few features.

The stimulus controller simply calls RailsSuperTokens.render(this.element) when it connects - and RailsSuperTokens is my wrapper around the SuperTokens javascript library.

import * as React from "react";
import * as ReactDOM from "react-dom";
import SuperTokens, { SuperTokensWrapper } from "supertokens-auth-react";
import ThirdPartyEmailPassword, {
  Google,
  Apple,
} from "supertokens-auth-react/recipe/thirdpartyemailpassword";
import Session from "supertokens-auth-react/recipe/session";

const appName = () => {
  return document.head.querySelector("meta[name=app-name]").content;
};
const apiDomain = () => {
  return document.head.querySelector("meta[name=api-domain]").content;
};
const websiteDomain = () => {
  return document.head.querySelector("meta[name=website-domain]").content;
};
const authenticationUrl = () => {
  return document.head.querySelector("meta[name=authentication-url]").content;
};
const hasRailsSession = () => {
  return (
    document.head.querySelector("meta[name=has-rails-session]").content ===
    "true"
  );
};

export async function serverLogin() {
  if (!hasRailsSession() && (await Session.doesSessionExist())) {
    const jwt = (await Session.getAccessTokenPayloadSecurely()).jwt;
    const formData = new FormData();
    formData.set("jwt", jwt.toString());
    await window.fetch(authenticationUrl(), {
      method: "PUT",
      mode: "same-origin",
      body: formData,
    });
    window.location.reload();
  }
}

export async function serverLogout() {
  const jwt = (await Session.getAccessTokenPayloadSecurely()).jwt;
  await fetch(authenticationUrl(), {
    method: "DELETE",
  });
}

function Component(props) {
  if (SuperTokens.canHandleRoute()) {
    return SuperTokens.getRoutingComponent();
  }
  return "Route not found";
}

export function init() {
  SuperTokens.init({
    appInfo: {
      appName: appName(),
      apiDomain: apiDomain(),
      websiteDomain: websiteDomain(),
      apiBasePath: "/auth",
      websiteBasePath: "/auth",
    },
    recipeList: [
      ThirdPartyEmailPassword.init({
        signInAndUpFeature: {
          providers: [Google.init(), Apple.init()],
          useShadowDom: false,
        },
      }),
      Session.init(),
    ],
  });
}

export function render(element) {
  ReactDOM.render(React.createElement(Component), element);
}

At the top, we have some helper methods that inspect the current page to see if various tags are set. We'll get to those later. Next up are a couple of functions to control logging in and out. The Component function is the React bit, creating the SuperTokens login UI if the route matches.

The main SuperTokens stuff happens in the init function. It pulls the information it needs out of the page meta tags, loads up a SuperTokens recipe (in this case, email/password login, plus Sign in with Apple and Google). We switch React's shadow DOM off (so that password managers can see the password field) then we tell SuperTokens to initialise the session using our parameters.

The last bit of the file exports the render function - which is what the Stimulus controller calls to draw the login panel.

Who are ya? who are ya?

So if we tell our Rails app to check if the user is logged in, using a standard before_action callback, and redirect to /auth then our app will now show a login panel which can handle email/password authentication, Sign in with Apple and Google authentication, all in one go. Your login details are sent to the provider (or your SuperTokens back-end for email/password), validated, and then SuperTokens will know who you are. Even better, if you use Safari, Sign in with Apple will give you biometric authentication. If you use Chrome, you'll be automatically signed in to your Google account.

And on the front-end, SuperTokens will have a token for you, stored in its Session object, describing who you are.

Now if we were using a SuperTokens-integrated back-end, that would be the end of the story. The front-end has a session and passes it to the back-end every time you make an API call. The back-end uses the SuperTokens library to figure out who you are and what you can do (by asking the Core) and everything progresses nicely.

But we're using a Rails back-end. And Rails doesn't know what to do with the SuperTokens session.

We'll deal with that in part two.

Rahoul Baruah

Rahoul Baruah

Rubyist since 1.8.6. I like hair, dogs and Kim/Charli/Poppy. Also CTO at Collabor8Online.
Leeds, England