Skip to content

Configuration

Nothing in the repository names a host, a domain, or a secret — those are facts about a deployment. Everything arrives through the environment.

POSTGRES_HOST POSTGRES_PORT where the database is
POSTGRES_USER POSTGRES_PASSWORD must not be a superuser — RLS is bypassed by one
POSTGRES_DATABASE production only
SECRET_KEY_BASE required in production
ENCRYPTION_PRIMARY_KEY ActiveRecord encryption
ENCRYPTION_DETERMINISTIC_KEY
ENCRYPTION_KEY_DERIVATION_SALT
MASKS_HOST_SUFFIX the suffix tenant subdomains hang off, development only
MASKS_PUBLIC_ORIGIN_TEMPLATE overrides the derived issuer origin; takes %{subdomain}
PORT

Generate real encryption keys with bin/rails db:encryption:init. The defaults in .env.example are development values and are labelled as such.

MASKS_TENANTS comma or space separated; ensured on boot
MASKS_SETUP_TOKEN required to answer the setup prompt, when set

Declaring MASKS_TENANTS turns claiming off. Leave it unset and the first request to a hostname nothing serves claims that subdomain — once, and only while no tenant exists at all, so it cannot be used to mint tenants against a wildcard. See connecting an app.

Each is a count per tenant; the windows are fixed. Hardening explains why sign-in carries two of them.

MASKS_ATTEMPT_LIMIT sign-in and second factor, per address — default 10
MASKS_ACCOUNT_ATTEMPT_LIMIT sign-in, per identifier — default 5
MASKS_REGISTRATION_LIMIT dynamic client registration — default 10
PG_BIN_PATH prepended to PATH, when the linked pg_dump is older than the server

The schema is dumped as SQL rather than Ruby, because schema.rb cannot represent a row-level security policy — so pg_dump has to match the server it dumps, and refuses outright when it does not. bin/setup checks for one before it does anything else.

MASKS_PUBLIC_ORIGIN_TEMPLATE exists for the case where something in front of the app rewrites the host — a tunnel, or a proxy that does not set forwarding headers. It must be stable, because OAuth redirect URIs and token audiences are registered against it. Leave it blank otherwise.

One gem, masks, holds both halves of the consumer side. require "masks" loads the client always and the Rails engine only when Rails is already loaded, so there is nothing to configure to keep the framework out of a service that does not want it.

The generator writes this file. Everything in it has a default except issuer.

Masks::Rails.configure do |config|
config.issuer = "https://jons.auth.example"
config.credentials = -> { { client_id: ENV["MASKS_CLIENT_ID"], client_secret: ENV["MASKS_CLIENT_SECRET"] } }
config.store = ->(request, registration) { Credentials.store!(registration) }
config.name = "things"
config.resource = nil
config.resource_scopes = []
config.scope = %w[openid profile email]
end

Connecting

issuer required — the masks issuer this app signs in against
name what the approval screen calls this app; defaults to the application’s module name
credentials client_id and client_secret, or nil when the app has not been connected
store given (request, registration) after a handshake — write them down
forget given (request) to drop them again; an app that sets store and not this is never offered a disconnect
credentials_path config/masks.json where the default store writes, when you set neither

Asking

scope openid profile email what to request, and what the handshake asks to be approved for
resource nil the API the access token is for — sets its aud, and what the handshake asks about
resource_scopes [] scope to description, published as RFC 9728 metadata
redirect_uri this host’s /auth/callback must match a URI the client was approved for

Behaviour

after_sign_in / where to land, unless a return path was remembered
after_sign_out / where to land after signing out
sign_out_of_issuer false make every sign-out an RP-initiated logout at the issuer
session_key masks the session key tokens are stored under
parent_controller ActionController::Base what the engine’s own pages inherit, for your layout
authenticate_everything false include Authentication on every controller rather than where you say

Omit the secret for a public client. An app with no credentials yet is not misconfigured — it is unconnected, and every refusal says so.

Every one of these accepts a callable taking the request, which is how one application serves many tenants:

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

No global configuration. Construct what you need:

Masks::Client.issuer(url) # discovery and JWKS, cached
Masks::Client.verifier(url, audience:) # for a resource server
Masks::Client::Session.new(issuer:, client_id:, redirect_uri:, client_secret:, scope:)
Masks::Client::Registration.create(issuer, token: nil, **metadata)
Masks::Client::Handshake.new(issuer, name:, resource:, redirect_uris:, return_to:, scope:)

A Registration carries client_id, client_secret, access_token and uri, reads itself back with read, changes itself with update, archives itself with delete, and builds a Session with session(redirect_uri:).