Skip to content

The three pieces

server/ the standalone OIDC provider you run one
client/ masks any ruby app that talks to it, Rails or not
web/ @masks/client a single-page app, in either of two modes
I want to… Use
run an auth server server/
sign a Rails app in masks — the engine mounts itself
verify tokens in an API masks
drive the flow by hand, or in a non-Rails app masks
sign in an SPA in front of a Rails app masks + @masks/client in BFF mode
sign in an SPA with no backend of its own @masks/client in browser PKCE mode

A resource server — something that only receives bearer tokens and never signs anyone in — needs masks and nothing else, and never loads the Rails half. See verifying a token.

The question “should the engine be the only supported way in?” resolved as no, and it had the wrong shape. The first consumer skipped the engine because the engine had nothing for a resource server and nothing for an SPA — not because it preferred its own.

So there are three ways to consume masks, and they serve different consumers:

the engine a Rails app doing browser sign-in
@masks/client in BFF mode a same-origin SPA in front of that Rails app
@masks/client in PKCE mode a client that is neither

An earlier version shipped an engine, a host app, and a gem together. The engine-ness produced its worst bug: middleware and host-app controllers fighting over Set-Cookie, in ways that depended on the host app’s own filters.

A standalone server has no host app, so that class of problem does not exist. It owns its routes, its session, and its cookie, and nothing runs around it.

Because the consumer side genuinely is a Rails concern, and it is a different problem. The engine mounts three routes into your application — start, callback, logout — and gives you authenticate_masks!. It issues nothing. It holds no signing key. It talks to the server over HTTP like any other client would.

The two are never loaded into the same process.

It ships inside the masks gem rather than beside it because both halves are the consumer — the split was never client versus server. require "masks" loads the engine only when Rails is already loaded, so a Rack or Sinatra app pays nothing for it.

Every configuration value in the engine accepts a callable, so an application serving many tenants on subdomains resolves its issuer per request:

Masks::Rails.configure do |config|
config.issuer = ->(request) { "https://#{request.host.split(".").first}.auth.example" }
config.resource = ->(request) { "https://#{request.host}/mcp" }
end