From ed1fbc681a97799335fac6238af329cfa7f07464 Mon Sep 17 00:00:00 2001 From: Omar Date: Sun, 17 May 2026 12:55:53 -0500 Subject: [PATCH] Add optional Clerk OIDC login Adds omniauth_openid_connect with Clerk as the IdP, gated on CLERK_DISCOVERY_URL / CLERK_CLIENT_ID / CLERK_CLIENT_SECRET. When unset, behaves identically to upstream. Password login + 2FA preserved as fallback. Access gate (v1): email-domain allowlist via CLERK_ALLOWED_EMAIL_DOMAINS. First-time login auto-creates the User on the singleton Account with role=admin. Drops into the empty _omniauthable.html.erb partial DocuSeal already reserved in the login view. --- Gemfile | 2 ++ .../users/omniauth_callbacks_controller.rb | 25 ++++++++++++++++ app/models/user.rb | 30 ++++++++++++++++++- .../devise/sessions/_omniauthable.html.erb | 8 +++++ config/initializers/devise.rb | 16 ++++++++++ config/routes.rb | 10 +++++-- lib/docuseal.rb | 18 +++++++++++ 7 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 app/controllers/users/omniauth_callbacks_controller.rb diff --git a/Gemfile b/Gemfile index 4e2fd78e..bd89b72a 100644 --- a/Gemfile +++ b/Gemfile @@ -26,6 +26,8 @@ gem 'jwt', require: false gem 'lograge' gem 'numo-narray-alt', require: false gem 'oj' +gem 'omniauth_openid_connect' +gem 'omniauth-rails_csrf_protection' gem 'onnxruntime', require: false gem 'pagy' gem 'pg', require: false diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb new file mode 100644 index 00000000..2fdb56c0 --- /dev/null +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Users + class OmniauthCallbacksController < Devise::OmniauthCallbacksController + skip_before_action :verify_authenticity_token, only: [:clerk_oidc] + + def clerk_oidc + user = User.from_clerk_oidc(request.env['omniauth.auth']) + + if user&.persisted? + sign_in_and_redirect(user, event: :authentication) + set_flash_message(:notice, :success, kind: 'Clerk') if is_navigational_format? + else + flash[:alert] = I18n.t('clerk_oidc_login_not_allowed', + default: 'Sign-in not permitted for this account.') + redirect_to new_user_session_path + end + end + + def failure + flash[:alert] = I18n.t('clerk_oidc_login_failed', default: 'Clerk sign-in failed. Please try again.') + redirect_to new_user_session_path + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index b80ae769..f6bd157c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -69,7 +69,12 @@ class User < ApplicationRecord has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig' has_many :email_messages, dependent: :destroy, foreign_key: :author_id, inverse_of: :author - devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable, :lockable + if Docuseal.clerk_oidc_enabled? + devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable, :lockable, + :omniauthable, omniauth_providers: [:clerk_oidc] + else + devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable, :lockable + end attribute :role, :string, default: ADMIN_ROLE attribute :uuid, :string, default: -> { SecureRandom.uuid } @@ -121,4 +126,27 @@ class User < ApplicationRecord email end end + + def self.from_clerk_oidc(auth) + email = auth.info.email.to_s.downcase + return nil if email.blank? + return nil unless Docuseal.clerk_email_allowed?(email) + + account = Account.first + return nil if account.blank? + + user = active.find_by(email:) + return user if user + + first, *rest = auth.info.name.to_s.split(' ', 2) + + create!( + account:, + email:, + first_name: auth.info.first_name.presence || first, + last_name: auth.info.last_name.presence || rest.join(' '), + role: ADMIN_ROLE, + password: Devise.friendly_token(40) + ) + end end diff --git a/app/views/devise/sessions/_omniauthable.html.erb b/app/views/devise/sessions/_omniauthable.html.erb index e69de29b..595c4ea3 100644 --- a/app/views/devise/sessions/_omniauthable.html.erb +++ b/app/views/devise/sessions/_omniauthable.html.erb @@ -0,0 +1,8 @@ +<% if Docuseal.clerk_oidc_enabled? %> +
<%= t(:or, default: 'or') %>
+ <%= button_to user_clerk_oidc_omniauth_authorize_path, + data: { turbo: false }, + class: 'base-button base-button-outline w-full flex items-center justify-center gap-2' do %> + <%= t(:sign_in_with_clerk, default: 'Sign in with Clerk') %> + <% end %> +<% end %> diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index efa97448..5ca00f9b 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -334,6 +334,22 @@ Devise.setup do |config| # changed. Defaults to true, so a user is signed in automatically after changing a password. # config.sign_in_after_change_password = true + if Docuseal.clerk_oidc_enabled? + config.omniauth_path_prefix = '/users/auth' + config.omniauth :openid_connect, { + name: :clerk_oidc, + issuer: Docuseal::CLERK_DISCOVERY_URL.sub(%r{/\.well-known/openid-configuration\z}, ''), + discovery: true, + scope: %i[openid email profile], + response_type: :code, + client_options: { + identifier: Docuseal::CLERK_CLIENT_ID, + secret: Docuseal::CLERK_CLIENT_SECRET, + redirect_uri: "#{Docuseal::DEFAULT_APP_URL.sub(%r{/\z}, '')}/users/auth/clerk_oidc/callback" + } + } + end + ActiveSupport.run_load_hooks(:devise_config, config) end # rubocop:enable Metrics/BlockLength diff --git a/config/routes.rb b/config/routes.rb index 1da136c5..556d0d1d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,8 +14,14 @@ Rails.application.routes.draw do get 'up' => 'rails/health#show' get 'manifest' => 'pwa#manifest' - devise_for :users, path: '/', only: %i[sessions passwords], - controllers: { sessions: 'sessions', passwords: 'passwords' } + if Docuseal.clerk_oidc_enabled? + devise_for :users, path: '/', only: %i[sessions passwords omniauth_callbacks], + controllers: { sessions: 'sessions', passwords: 'passwords', + omniauth_callbacks: 'users/omniauth_callbacks' } + else + devise_for :users, path: '/', only: %i[sessions passwords], + controllers: { sessions: 'sessions', passwords: 'passwords' } + end devise_scope :user do resource :invitation, only: %i[update] do diff --git a/lib/docuseal.rb b/lib/docuseal.rb index 98caaf72..c03fde2d 100644 --- a/lib/docuseal.rb +++ b/lib/docuseal.rb @@ -37,6 +37,13 @@ module Docuseal CERTS = JSON.parse(ENV.fetch('CERTS', '{}')) TIMESERVER_URL = ENV.fetch('TIMESERVER_URL', nil) + + CLERK_DISCOVERY_URL = ENV.fetch('CLERK_DISCOVERY_URL', nil) + CLERK_CLIENT_ID = ENV.fetch('CLERK_CLIENT_ID', nil) + CLERK_CLIENT_SECRET = ENV.fetch('CLERK_CLIENT_SECRET', nil) + CLERK_ALLOWED_EMAIL_DOMAINS = ENV.fetch('CLERK_ALLOWED_EMAIL_DOMAINS', '') + .split(',').map { |d| d.strip.downcase }.compact_blank.freeze + VERSION_FILE_PATH = Rails.root.join('.version') VERSION_FILE2_PATH = Rails.public_path.join('version') @@ -60,6 +67,17 @@ module Docuseal ENV['MULTITENANT'] == 'true' end + def clerk_oidc_enabled? + CLERK_DISCOVERY_URL.present? && CLERK_CLIENT_ID.present? && CLERK_CLIENT_SECRET.present? + end + + def clerk_email_allowed?(email) + return true if CLERK_ALLOWED_EMAIL_DOMAINS.empty? + + domain = email.to_s.split('@').last.to_s.downcase + CLERK_ALLOWED_EMAIL_DOMAINS.include?(domain) + end + def advanced_formats? multitenant? end