From 9e41d3a5770063d6ee7fa63db9d54e793277282c Mon Sep 17 00:00:00 2001 From: Omar Date: Sun, 17 May 2026 16:45:55 -0500 Subject: [PATCH] Bridge Clerk apex SSO into Devise via clerk-sdk-ruby MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Gemfile | 1 + app/controllers/application_controller.rb | 16 +++++ .../concerns/clerk_devise_bridge.rb | 68 +++++++++++++++++++ app/controllers/sessions_controller.rb | 9 +++ config/initializers/clerk.rb | 10 +++ 5 files changed, 104 insertions(+) create mode 100644 app/controllers/concerns/clerk_devise_bridge.rb create mode 100644 config/initializers/clerk.rb diff --git a/Gemfile b/Gemfile index bd89b72a..2c99de56 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 592c006d..ae8fc056 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/concerns/clerk_devise_bridge.rb b/app/controllers/concerns/clerk_devise_bridge.rb new file mode 100644 index 00000000..4a1c735d --- /dev/null +++ b/app/controllers/concerns/clerk_devise_bridge.rb @@ -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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 2281d56f..7636b6f2 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -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' diff --git a/config/initializers/clerk.rb b/config/initializers/clerk.rb new file mode 100644 index 00000000..0782d622 --- /dev/null +++ b/config/initializers/clerk.rb @@ -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