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.
pull/688/head
Omar 1 month ago committed by Omar Shaarawi
parent 60082655d4
commit ed1fbc681a

@ -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

@ -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

@ -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
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

@ -0,0 +1,8 @@
<% if Docuseal.clerk_oidc_enabled? %>
<div class="divider text-base-content/40 text-sm my-6"><%= t(:or, default: 'or') %></div>
<%= 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 %>
<span><%= t(:sign_in_with_clerk, default: 'Sign in with Clerk') %></span>
<% end %>
<% end %>

@ -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

@ -14,8 +14,14 @@ Rails.application.routes.draw do
get 'up' => 'rails/health#show'
get 'manifest' => 'pwa#manifest'
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

@ -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

Loading…
Cancel
Save