JWTs and JWKS (JSON Web Tokens and JSON Web Key Sets)
Supertokens is a pre-built open-source authentication system that takes the hassle out of authenticating your users with third party providers. Rails already has Devise and Omniauth, which are great, but Supertokens also separates your private data store from your application's data, adding another layer to your security onion.
The only trouble is that Supertokens doesn't speak Ruby. This is how I'm dealing with it.
Again, the caveat here is I've not used this in production yet. It works great on my development environment, and I'll be setting up a staging environment very soon.
The Supertokens back-end is available in Go, Python and Javascript. It takes the session token that the front-end has obtained (as we saw last time) and then passes that to your server code to keep you safe.
However, in Rails, we can't access the back-end directly.
The Rails views use the Supertokens javascript to obtain a session from the back-end, then rails_super_tokens.js
extracts a JSON Web Token (JWT) and passes that to my Rails server. The Rails app then sets its own session variable which can be used in the traditional Rails style.
In my Rails views, I add in some meta-tags into the page head.
<meta name="app-name" content="<%= ENV["APP_NAME"] %>">
<meta name="api-domain" content="<%= ENV["AUTH_API_URL"] %>">
<meta name="website-domain" content="<%= ENV["AUTH_WEBSITE_URL"] %>">
<meta name="authentication-url" content="<%= auth_path %>">
<meta name="has-rails-session" content="<%= identity.present? %>">
We'll get to the first three later on. But the important ones for now are the authentication-url
and the has-rails-session
tags.
has-rails-session
is a boolean that shows whether the Rails app has a current identity
(I don't like calling people "users", it makes them sound abusive). The authentication-url
is used by the client-side Javascript to post credentials to Rails.
In my rails_super_tokens.js
file, we have the following functions:
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(newUser) {
if (!hasRailsSession() && (await Session.doesSessionExist())) {
const jwt = (await Session.getAccessTokenPayloadSecurely()).jwt
const formData = new FormData()
formData.set("jwt", jwt.toString())
formData.set("newUser", newUser)
await window.fetch(authenticationUrl(), {
method: "PUT",
mode: "same-origin",
body: formData,
})
window.location.reload()
}
}
The serverLogin
function checks the meta-tags to see if Rails has a current identity. If not, it then asks SuperTokens if it has a current session. If so, that means we have logged in on the front-end but the server doesn't know who we are yet. So the javascript extracts the JWT from Supertokens and posts it to the authentication_url
that is stored in another meta-tag. After posting, it reloads the current page.
This serverLogin
function is triggered when each page is rendered - in my case on turbo:load
, as Hotwire does not do a full page reload.
The JWT is pretty simple - it simply stores the sub
claim, which identifies the user-id that Supertokens has given us, plus an expiry.
Quick note about JWTs if you're not familiar with them.
They are basically a JSON object (equivalent to a ruby hash), that is cryptographically signed and often encrypted. They have some standard fields within them (known as claims). The most important are sub
, which is the subject of the claim (the user) and an expiry (because all JWTs have a limited lifespan).
There are a number of other claims, which can be used to show which authorisations and permissions this user has, but I'm not interested in them at the moment.
The point of JWTs is so that a standard token can be passed around multiple systems without those systems all having to call back to a centralised identity server to verify that the user is who they say they are. If the JWT has not been tampered with (proven by the signature) and has not expired, any service that receives the JWT can be sure that the sub
is a valid user identifier.
One the server, the AuthenticationController#update
method receives this JWT and unpacks it.
if !Rails.env.test?
# Do a real login
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
else
# Do a fake login for the tests
def update
identity = Identity.find_by reference: params[:reference]
session[:identity_id] = identity&.id
redirect_to root_path
end
end
First thing to note is that I don't want my full-stack tests using Supertokens - they will fill my Supertokens core database with crap, plus it's a pain getting Capybara to log in that way. So in test mode, I render a dummy login page and the tests use it to pass a reference
parameter that the update
action uses to load an identity from the test database.
But when we're not in test mode, rails_super_tokens.js
PUTs the JWT it has obtained, then the server extracts the subject and uses that to locate the current identity.
The actual work is done by a JWT module that is included into the controller. I pieced this together from several different articles about accessing JWTs in Rails; each was slightly different and none worked out of the box. So this may or may not work for you.
require "net/http"
module Jwt
extend ActiveSupport::Concern
protected
def get_reference_from_jwt(jwt)
return nil if jwt.blank?
token = decode(jwt).first
token.blank? ? nil : token["sub"]
end
def decode(token)
JWT.decode(token, nil, true, iss: Rails.configuration.issuer_url, verify_iss: true, algorithms: ["RS256"], jwks: jwk_loader)
rescue => ex
Rails.logger.error ex
[{}]
end
private
def jwk_loader
->(options) { jwks(force: options[:invalidate]) || {} }
end
def uri
URI(Rails.configuration.jwks_url)
end
def fetch_jwks
Net::HTTP.start(uri.host, uri.port) do |http|
request = Net::HTTP::Get.new uri
response = http.request request
return JSON.parse(response.body.to_s) if response.code.to_i == 200
end
end
def jwks(force: false)
Rails.cache.fetch(Rails.configuration.jwks_url, force: force, skip_nil: true) do
fetch_jwks
end&.deep_symbolize_keys
end
end
The controller calls get_reference_from_jwt
which decodes the token and returns the sub
(subject) claim from it. The decode routine uses the standard ruby JWT
gem, with some extras tacked on.
Firstly we check the issuer - this is to prove that the JWT was issued by someone we trust. In my case, I store the expected issuer URL in my configuration - and we'll talk about that next time.
Secondly, we give the JWT routine a JWKS loader.
The JSON Web Key Set (JWKS) is another JSON document, this time describing the cryptographic keys used to sign and encrypt the JWT. This is how we can be sure our JWT hasn't been tampered with.
The JWKS loader is a ruby proc
that takes some options - the important one being invalidate
. This is because we don't want to be hitting the identity server every time we want to verify a JWT - the point of JWTs is we can trust them without asking someone else. So we want to cache the results of the JWKS call, as well as refreshing that cache on demand.
The loader calls the jwks
function which checks the cache and if the cache is empty, it calls fetch_jwks
.
In fetch_jwks
, we look up the JWKS URL from the Rails configuration, then use good old Net::HTTP
to open a GET
request to our JWKS server. Finally we read and parse the response, which is then passed back to the JWT gem's decode routine.
The JWKS document is pretty simple, listing the cryptographic algorithm used, along with various parameters that I don't understand, allowing the decode routine to decrypt the JWT, check its signature and verify that it has not expired.
Once the gem has decoded the JWT, the sub
is extracted, and the AuthenticationController
uses that to look up the relevant model (in my case Identity) in the Rails app's database, finally storing that Identity ID in the Rails session.
Which means that, for as long as the Rails session is valid, the front-end does not need to pass the JWT around and instead, the server code will know who the current identity is. Just as if we were using Devise and storing a user_id
.
There's one more thing to go through though. Who do we ask for this JWKS document? And how does the front-end obtain the JWT in the first place?
I can tell you're on the edge of your seat, but you're going to have to wait for that bit.