The three pieces
server/ the standalone OIDC provider you run oneclient/ masks any ruby app that talks to it, Rails or notweb/ @masks/client a single-page app, in either of two modesWhich do I need?
Section titled “Which do I need?”| 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.
Three consumer paths, not one
Section titled “Three consumer paths, not one”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 |
Why the server is standalone
Section titled “Why the server is standalone”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.
Why there is still an engine
Section titled “Why there is still an engine”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.
Multi-tenant hosts
Section titled “Multi-tenant hosts”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