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