Skip to content

Sign a Rails app in

# Gemfile
gem "masks"
Terminal window
bin/rails generate masks:install

That mounts the engine at /auth, writes config/initializers/masks.rb, and gitignores the file credentials land in. --mount puts it somewhere else; --resource fills in the identifier when this app also accepts tokens of its own.

One gem carries both halves, and the engine loads only when Rails does — so the same gem "masks" in a Rack or API-only app pulls in jwt and nothing else. See verifying a token.

If you would rather do it by hand, the whole of the generator’s routing is one line:

config/routes.rb
mount Masks::Rails::Engine, at: "/auth"
config/initializers/masks.rb
Masks::Rails.configure do |config|
config.issuer = ENV.fetch("MASKS_ISSUER")
config.credentials = -> { { client_id: ENV["MASKS_CLIENT_ID"], client_secret: ENV["MASKS_CLIENT_SECRET"] } }
config.scope = %w[openid profile email offline_access]
config.after_sign_in = "/"
end

If you have no credentials to name — which is the usual case, since nobody should be copying a secret into an environment file — leave credentials reading wherever you store them and give the engine a store. It will run the handshake at /auth/handshake and put them there.

Every value also accepts a callable taking the request, which is how a subdomain-per-tenant application points each host at its own issuer:

config.issuer = ->(request) { "https://#{request.host.split(".").first}.auth.example" }

redirect_uri defaults to your /auth/callback on the requesting host, so it usually needs no configuration at all — but it must match a URI the client was approved for.

class ApplicationController < ActionController::Base
before_action :authenticate_masks!
end

authenticate_masks! returns true if there is a live session, silently refreshes if the access token has expired and a refresh token is available, and otherwise remembers where you were headed and redirects into the flow. An app holding no credentials yet is sent to the handshake instead, and a JSON caller is answered handshake_required — never a login it cannot complete.

masks_signed_in? is there an unexpired token
masks_identity verified id token claims — sub, preferred_username, email
masks_tenant the tenant claim: uuid, subdomain, name
masks_tokens the raw Tokens, including access_token for calling APIs

The first three are available in views.

<% if masks_signed_in? %>
Signed in as <%= masks_identity["preferred_username"] %>
at <%= masks_tenant["name"] %>
<% end %>

masks owns tenants; your application holds a projection keyed by the uuid.

def current_tenant
claim = masks_tenant or return nil
Tenant.find_or_create_by!(uuid: claim["uuid"]) do |tenant|
tenant.subdomain = claim["subdomain"]
tenant.name = claim["name"]
end
end

Key on uuid, not subdomain — a subdomain is routing and can be renamed.

state is compared in constant time against the value stashed when the flow began, and the id token’s nonce is compared against its own stashed value. Both fail closed. The PKCE verifier is deleted from the session as it is used, so a replayed callback has nothing to exchange with.