Bridge Clerk apex SSO into Devise via clerk-sdk-ruby

Reads the __session cookie set by accounts.bloombilt.com on the .bloombilt.com
apex, verifies it via the official Clerk Ruby SDK, then finds or
auto-provisions the matching Devise User on Account.first so the rest of the
app (CanCanCan + Devise) sees the request as authenticated. Sign-out and
unauthed redirects both target accounts.bloombilt.com/sign-in so 1Password
sees a single saved entry across all Bloombilt apps.

This is independent of the dead Clerk OIDC code already on master — that
path requires Clerk Pro to register an OAuth Application on the production
instance and is left dormant (gated by Docuseal.clerk_oidc_enabled?) in case
we upgrade later. The session-cookie bridge works on Clerk free.

Devise password login at /users/sign_in stays reachable as emergency access
but isn't linked from the UI.

Files:
- Gemfile: add clerk-sdk-ruby (requires bundle install)
- config/initializers/clerk.rb: SDK config (uses ENV['CLERK_SECRET_KEY'])
- app/controllers/concerns/clerk_devise_bridge.rb: the bridge itself
- app/controllers/application_controller.rb: include the concern, override
  authenticate_user! to redirect to Account Portal
- app/controllers/sessions_controller.rb: override respond_to_on_destroy to
  send sign-out to Account Portal

Gemfile.lock NOT updated in this commit — needs `bundle install` on a host
with Ruby 4.0.1 before deploy will succeed.
pull/688/head
Omar 1 month ago committed by Omar Shaarawi
parent 232380d378
commit 9e41d3a577

@ -11,6 +11,7 @@ gem 'aws-sdk-secretsmanager', require: false
gem 'azure-blob', require: false
gem 'bootsnap', require: false
gem 'cancancan'
gem 'clerk-sdk-ruby', require: 'clerk'
gem 'csv', require: false
gem 'csv-safe', require: false
gem 'devise'

@ -2,9 +2,12 @@
class ApplicationController < ActionController::Base
BROWSER_LOCALE_REGEXP = /\A\w{2}(?:-\w{2})?/
ACCOUNT_PORTAL_SIGN_IN_URL = 'https://accounts.bloombilt.com/sign-in'
include ActiveStorage::SetCurrent
include Pagy::Method
include Clerk::Authenticatable
include ClerkDeviseBridge
check_authorization unless: :devise_controller?
@ -45,6 +48,19 @@ class ApplicationController < ActionController::Base
Docuseal.default_url_options
end
# Override Devise's authenticate_user! to redirect unauthed users to the
# Clerk Account Portal at accounts.bloombilt.com instead of the local Devise
# sign-in page. ClerkDeviseBridge has already attempted to sign them in via
# the apex __session cookie by this point; if user_signed_in? is still false
# we hand off to Clerk. Devise's password form at /users/sign_in stays
# reachable directly as emergency access.
def authenticate_user!(_opts = {})
return if user_signed_in?
redirect_to "#{ACCOUNT_PORTAL_SIGN_IN_URL}?redirect_url=#{CGI.escape(request.original_url)}",
allow_other_host: true
end
def impersonate_user(user)
raise ArgumentError unless user
raise Pretender::Error unless true_user

@ -0,0 +1,68 @@
# frozen_string_literal: true
# Bridges Clerk's apex-cookie SSO into Devise. When a request arrives with a
# valid Clerk session JWT belonging to the Bloombilt Staff org, find or
# auto-provision the matching Devise User and call sign_in so the rest of the
# app (which authorizes via Devise + CanCanCan) sees the request as
# authenticated. Devise's password login at /users/sign_in is preserved as
# emergency access.
module ClerkDeviseBridge
extend ActiveSupport::Concern
STAFF_ORG_SLUG = 'bloombilt-staff'
included do
before_action :sign_in_from_clerk_session, unless: :devise_controller?
end
private
def sign_in_from_clerk_session
return if user_signed_in?
clerk_user = safe_clerk_user
return unless clerk_user
return unless clerk.organization&.slug == STAFF_ORG_SLUG
email = primary_email(clerk_user)
return unless email
user = User.active.find_by(email: email) || provision_user_for_clerk(email, clerk_user)
return unless user
sign_in(user, store: true)
end
def safe_clerk_user
clerk.user
rescue StandardError => e
Rails.logger.warn("[clerk-bridge] reading clerk.user failed: #{e.class}: #{e.message}")
nil
end
def primary_email(clerk_user)
addresses = clerk_user.respond_to?(:email_addresses) ? clerk_user.email_addresses : nil
addresses&.first&.email_address&.to_s&.downcase.presence
end
def provision_user_for_clerk(email, clerk_user)
account = Account.first
return nil unless account
first_name = clerk_user.respond_to?(:first_name) ? clerk_user.first_name : nil
last_name = clerk_user.respond_to?(:last_name) ? clerk_user.last_name : nil
User.create!(
account: account,
email: email,
first_name: first_name.presence,
last_name: last_name.presence,
role: User::ADMIN_ROLE,
password: Devise.friendly_token(40)
)
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
Rails.logger.warn("[clerk-bridge] provision failed for #{email}: #{e.message}")
User.active.find_by(email: email)
end
end

@ -38,6 +38,15 @@ class SessionsController < Devise::SessionsController
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end
# Sign-out lands the user on the Clerk Account Portal so a fresh sign-in
# establishes the apex __session cookie shared across all Bloombilt apps.
# Replaces Devise's default respond_to_on_destroy, which renders the local
# password sign-in page that the Clerk migration deliberately leaves
# unlinked from the UI.
def respond_to_on_destroy
redirect_to ApplicationController::ACCOUNT_PORTAL_SIGN_IN_URL, allow_other_host: true
end
def set_flash_message(key, kind, options = {})
return if key == :alert && kind == 'already_authenticated'

@ -0,0 +1,10 @@
# frozen_string_literal: true
# Clerk SDK config — secret_key falls back to ENV['CLERK_SECRET_KEY'].
# The Rack middleware is auto-mounted when the SDK is required, which
# reads the __session cookie set by accounts.bloombilt.com on the apex
# .bloombilt.com domain and exposes the verified user via the `clerk`
# helper in controllers that include Clerk::Authenticatable.
Clerk.configure do |c|
c.logger = Rails.logger
end
Loading…
Cancel
Save