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 nilauthenticate_<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) and GatewayConnection (ActionCable upgrade).
  • Service-to-service calls carry no X-User-Id and therefore never trigger the hook.
  • Exceptions raised inside the hook propagate — a failure becomes a 500 on the app side. Rescue inside your own callable to convert to nil (→ 401) when that's what you want.
  • Any object responding to :call with 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_name is captured at include time. 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 of X-Client-Id / X-User-Id. user_id is 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
    (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.