fluence-gateway-auth
HMAC authentication bridge between the Fluence API Gateway and a Rails (or plain Rack) application. The gateway handles OAuth and user resolution upstream; this gem verifies the gateway's signature on every request and exposes the identity it forwards.
Four pieces, all auto-wired in Rails through a Railtie:
| Piece | Role |
|---|---|
Fluence::Gateway::Auth::Middleware |
Rack middleware that verifies the HMAC-SHA256 signature on every request, with a 30 s replay window. |
Fluence::Gateway::Auth::GatewayAuthentication |
Controller concern — generates current_<scope> / <scope>_signed_in? / authenticate_<scope>!. |
Fluence::Gateway::Auth::GatewayConnection |
ActionCable connection concern — resolves current_<scope> on the WebSocket upgrade. |
FluenceGatewayAuth::Generators::InstallGenerator |
rails generate fluence_gateway_auth:install — initializer + model + migration. |
Requires Ruby ≥ 3.2, ActiveSupport ≥ 7.0, Rack ≥ 2.0.
Installation
# Gemfile
gem 'fluence-gateway-auth', git: 'https://github.com/fluence-eu/fluence-gateway-auth'
bundle install
rails generate fluence_gateway_auth:install AdminUser
rails db:migrate
Generator
Positional argument: the model class name (defaults to User). The generator inspects app/models/<file_path>.rb in the target application and branches:
| Situation | Emitted files |
|---|---|
| Model file already exists | config/initializers/fluence_gateway_auth.rb + an add_<column>_to_<table> migration (unique index on the new column). |
| Model file missing | Initializer + create_<table> migration (email:string:uniq, first_name:string, last_name:string + <column>:string:uniq) + the model scaffolded via active_record:model. |
Both branches invoke the native active_record:migration and active_record:model generators; the only gem-owned template is the initializer.
Flags:
| Flag | Default | Effect |
|---|---|---|
--column |
gateway_subject |
Column added to the table and used for the subject lookup. |
--skip-migration |
false |
Skip the migration step. |
--skip-model |
false |
Skip the model scaffolding. |
rails generate fluence_gateway_auth:install # User, default column
rails generate fluence_gateway_auth:install AdminUser # AdminUser, default column
rails generate fluence_gateway_auth:install AdminUser --column=external_id # custom column
rails generate fluence_gateway_auth:install Admin::User # namespaced model
rails generate fluence_gateway_auth:install AdminUser --skip-migration # column already exists
Configuration
The generator writes config/initializers/fluence_gateway_auth.rb. The full surface:
Fluence::Gateway::Auth.configure do |config|
config.user_model = 'AdminUser'
# config.subject_column = :gateway_subject
# config.scope_name = :member
# config.on_missing_user = ->(subject:, email:, first_name:, last_name:, scopes:, client_id:) { ... }
config.hmac_secret = ENV['GATEWAY_HMAC_SECRET']
end
| Setting | Type | Default | Behaviour |
|---|---|---|---|
user_model |
String | 'User' |
Class resolved at request time via constantize. Raises InvalidUserModel on assignment if blank. |
subject_column |
Symbol | :gateway_subject |
Column on user_model matched against the X-User-Id header via find_by. |
scope_name |
Symbol | user_model.demodulize.underscore.to_sym |
Prefix for the generated controller helpers. |
on_missing_user |
#call or nil |
nil |
Just-in-time provisioning hook (see below). Must respond to :call or be nil, else InvalidOnMissingUser. |
skip_middleware |
Boolean | false |
Bypasses HMAC verification when true. TestHelpers flips this on load. |
hmac_secret |
String | ENV['GATEWAY_HMAC_SECRET'] |
Shared secret. Reading it raises MissingHmacSecret when neither the attribute nor the env var is set. |
Both scope_name and subject_column must match /\A[a-z_][a-z0-9_]*\z/ — they end up as method names and column identifiers. Violations raise InvalidScopeName / InvalidSubjectColumn.
Derivation examples: 'User' → :user, 'AdminUser' → :admin_user, 'Admin::Member' → :member. Set scope_name explicitly when the derivation doesn't produce the helper name you want.
Just-in-time user provisioning
A gateway call for a user that doesn't exist locally resolves to nil → authenticate_<scope>! responds 401. If you'd rather create the record on first contact, set on_missing_user. It is invoked after a failed find_by, with the full set of gateway-supplied identity kwargs, and its return value becomes the resolved user (return nil to keep the default 401).
Fluence::Gateway::Auth.configure do |config|
config.user_model = 'AdminUser'
config.on_missing_user = ->(subject:, email:, first_name:, last_name:, scopes:, client_id:) {
return nil unless %w[mobile-app web-app].include?(client_id)
AdminUser.create!(
gateway_subject: subject,
email: email,
first_name: first_name,
last_name: last_name
)
}
end
Notes:
- The same hook is called by both
GatewayAuthentication(HTTP) andGatewayConnection(ActionCable upgrade). - Service-to-service calls carry no
X-User-Idand therefore never trigger the hook. - Exceptions raised inside the hook propagate — a failure becomes a
500on the app side. Rescue inside your own callable to convert tonil(→401) when that's what you want. - Any object responding to
:callwith the same kwargs works (UserProvisioner.new(audit: true)etc.).
Usage
HMAC verification is automatic
The Railtie installs Middleware at boot. Requests without a valid signature are rejected with 403 before reaching your controllers. In development, test, or outside Rails, the 403 body carries the specific rejection reason (missing_gateway_headers, timestamp_out_of_window, invalid_signature); in production it always reads {"message":"Forbidden"}.
Plain Rack applications can insert it manually:
use Fluence::Gateway::Auth::Middleware # reads configuration
use Fluence::Gateway::Auth::Middleware, hmac_secret: 'secret' # explicit override
Controller helpers
GatewayAuthentication is included into ActionController::API and ActionController::Base via ActiveSupport.on_load. With user_model = 'AdminUser' (scope :admin_user):
class ProjectsController < ApplicationController
before_action :authenticate_admin_user!
def index
render json: current_admin_user.projects
end
end
All helpers (names follow scope_name):
| Method | Returns |
|---|---|
current_<scope> |
User record from model_class.find_by(subject_column => gateway_user_id), memoised per request in @current_<scope>. Falls through on_missing_user on a miss. |
<scope>_signed_in? |
true when current_<scope> is present. |
authenticate_<scope>! |
head :unauthorized unless <scope>_signed_in?. |
gateway_user_id |
Raw X-User-Id header. |
gateway_user_email |
Raw X-User-Email header. |
gateway_user_first_name |
Raw X-User-First-Name header. |
gateway_user_last_name |
Raw X-User-Last-Name header. |
gateway_user_scopes |
Raw X-User-Scopes header (space-separated OAuth scopes). |
gateway_client_id |
Raw X-Client-Id header (Doorkeeper application uid). |
service_request? |
true when gateway_client_id is present and gateway_user_id is blank (client-credentials call). |
gateway_logout_path |
Constant '/auth/logout'. Registered as a helper method when the host supports it. |
scope_nameis captured atincludetime. Changing it after controllers have loaded has no effect on already-defined helpers.
<%= link_to 'Logout', gateway_logout_path, data: { turbo_method: :delete } %>
ActionCable connections
GatewayConnection is included into every ActionCable::Connection::Base subclass via on_load(:action_cable_connection). It calls identified_by :current_<scope> (where <scope> is Configuration#scope_name) and resolves the record from the same gateway headers the HTTP middleware validates — the gateway signs the upgrade request just like any other.
The concern is permissive by default: current_<scope> is assigned (possibly to nil) and the upgrade proceeds. Call authenticate_connection! in the class body to reject anonymous upgrades with reject_unauthorized_connection:
module ApplicationCable
class Connection < ActionCable::Connection::Base
authenticate_connection!
end
end
Lookup and on_missing_user fallback behave identically to the controller path. The identifier name follows scope_name, so with user_model = 'Account' channels access current_account rather than current_user.
HMAC contract
The middleware recomputes and constant-time-compares:
HMAC-SHA256(hmac_secret, "<METHOD>|<timestamp>|<client_id>|<user_id>|<fullpath>|<body_sha256>")
<METHOD>— upper-case HTTP verb (GET,POST, …).<timestamp>— Unix seconds. Rejected when|now - timestamp| > 30.<client_id>/<user_id>— echo ofX-Client-Id/X-User-Id.user_idis empty on service calls.<fullpath>—Rack::Request#fullpath, i.e. path including query string.<body_sha256>— hex SHA-256 of the raw request body (Digest::SHA256.hexdigest("")for empty bodies).
Comparison uses ActiveSupport::SecurityUtils.secure_compare. The body is read once and rewound, so downstream middlewares still observe the full payload.
Expected headers:
| Header | Required | Notes |
|---|---|---|
X-Gateway-Timestamp |
yes | Unix seconds; 30 s drift window. |
X-Gateway-Signature |
yes | Hex HMAC-SHA256 digest. |
X-Client-Id |
yes | Doorkeeper application uid. |
X-User-Id |
user calls | Gateway subject. Missing on service-to-service. |
X-User-Email |
user calls | Email of the authenticated user. |
X-User-First-Name |
optional | First name, when the gateway has it. |
X-User-Last-Name |
optional | Last name, when the gateway has it. |
X-User-Scopes |
user calls | Granted OAuth scopes, space-separated. |
Rejection codes (only surfaced in dev/test):
| Code | Condition |
|---|---|
missing_gateway_headers |
Any of X-Gateway-Timestamp / X-Gateway-Signature / X-Client-Id missing. |
timestamp_out_of_window |
Timestamp older or newer than 30 s relative to the backend clock. |
invalid_signature |
Signature present but doesn't match the expected value. |
Testing
fluence/gateway/auth/test_helpers is a test-only module. Requiring it has a global side effect: it flips skip_middleware to true for the entire process, so the middleware becomes a pass-through.
# spec/rails_helper.rb
require 'fluence/gateway/auth/test_helpers'
RSpec.configure do |config|
config.include Fluence::Gateway::Auth::TestHelpers, type: :request
end
No need to config.middleware.delete Fluence::Gateway::Auth::Middleware in config/environments/test.rb. Never require this file from production code.
Included helpers:
| Method | Behaviour |
|---|---|
sign_in_as(user) |
Stores user in @gateway_test_user; used as the default for gateway_headers_for. |
gateway_headers_for(user = @gateway_test_user, client_id: 'test-client') |
Returns {} when user is nil, otherwise X-User-Id + X-Client-Id + any of X-User-Email / X-User-First-Name / X-User-Last-Name / X-User-Scopes the user responds to (respond_to? check). |
RSpec.describe 'Projects API', type: :request do
let(:admin) { AdminUser.create!(email: 'a@b.c', gateway_subject: 'sub-1') }
it 'lists projects' do
sign_in_as(admin)
get '/projects', headers: gateway_headers_for
expect(response).to have_http_status(:ok)
end
end
X-User-Id reads user.public_send(subject_column).to_s, so any record with the configured column works. To exercise the real HMAC path in a specific suite, set Fluence::Gateway::Auth.configuration.skip_middleware = false in a before block — the helpers and the bypass are independent.
Error hierarchy
All gem-raised errors inherit from Fluence::Gateway::Auth::Error:
Error
└── ConfigurationError
├── InvalidUserModel
├── InvalidScopeName
├── InvalidSubjectColumn
├── InvalidOnMissingUser
└── MissingHmacSecret
Configuration errors surface at initializer load time or on the first hmac_secret read — the request path never raises them.
Development
bin/setup
bundle exec rake # rubocop + rspec
bundle exec rspec # tests only
bundle exec rubocop -a # autofix
bundle exec appraisal rake # run the suite across every supported Rails version (see Appraisals)
See CONTRIBUTING.md for the commit and PR conventions.
License
MIT. © Fluence.