feat: implement SSO authentication with SAML, Google, and Microsoft providers

pull/500/head
Adam Hesch 3 months ago
parent 5f04a2d098
commit f2f1745526

@ -0,0 +1,91 @@
FROM ruby:3.4.2-alpine AS download
WORKDIR /fonts
RUN apk --no-cache add fontforge wget && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.4.2-alpine AS app
# Development environment settings
ENV RAILS_ENV=development
ENV NODE_ENV=development
ENV BUNDLE_WITHOUT=""
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/app/openssl_legacy.cnf
WORKDIR /app
# Install system dependencies including nodejs and yarn for development
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && \
apk add --no-cache \
sqlite-dev \
libpq-dev \
mariadb-dev \
vips-dev@edge \
redis \
libheif@edge \
vips-heif@edge \
gcompat \
ttf-freefont \
nodejs \
yarn \
git \
build-base && \
mkdir /fonts && \
rm /usr/share/fonts/freefont/FreeSans.otf
# Copy fonts from download stage
COPY --from=download /fonts /fonts
COPY --from=download /pdfium-linux /pdfium-linux
# Install PDFium library
RUN cp /pdfium-linux/lib/libpdfium.so /usr/local/lib/ && \
chmod +x /usr/local/lib/libpdfium.so
# OpenSSL legacy configuration for compatibility
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\
[provider_sect]\n\
default = default_sect\n\
legacy = legacy_sect\n\
\n\
[default_sect]\n\
activate = 1\n\
\n\
[legacy_sect]\n\
activate = 1' >> /app/openssl_legacy.cnf
# Copy Gemfile and package.json for dependency installation
COPY ./Gemfile ./Gemfile.lock ./
COPY ./package.json ./yarn.lock ./
# Install Ruby gems and Node packages
RUN bundle install && \
yarn install --network-timeout 1000000
# Install shakapacker for asset compilation
RUN gem install shakapacker
# Create necessary directories
RUN mkdir -p log tmp/pids tmp/cache tmp/sockets public/assets
# Set up the application
COPY ./bin ./bin
RUN chmod +x ./bin/*
# Expose port 3000
EXPOSE 3000
# Default command for development
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]

@ -13,6 +13,11 @@ gem 'cancancan'
gem 'csv' gem 'csv'
gem 'devise' gem 'devise'
gem 'devise-two-factor' gem 'devise-two-factor'
gem 'omniauth'
gem 'omniauth-rails_csrf_protection'
gem 'omniauth-saml'
gem 'omniauth-google-oauth2'
gem 'omniauth-microsoft_graph'
gem 'dotenv', require: false gem 'dotenv', require: false
gem 'email_typo' gem 'email_typo'
gem 'faraday' gem 'faraday'

@ -270,6 +270,7 @@ GEM
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
hashdiff (1.1.2) hashdiff (1.1.2)
hashie (5.0.0)
hexapdf (1.0.3) hexapdf (1.0.3)
cmdparse (~> 3.0, >= 3.0.3) cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1) geom2d (~> 0.4, >= 0.4.1)
@ -323,6 +324,8 @@ GEM
minitest (5.25.4) minitest (5.25.4)
msgpack (1.7.5) msgpack (1.7.5)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.7.2)
bigdecimal (~> 3.1)
multipart-post (2.4.1) multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
mysql2 (0.5.6) mysql2 (0.5.6)
@ -351,9 +354,39 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-musl) nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
oauth2 (2.0.12)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.3)
version_gem (>= 1.1.8, < 3)
oj (3.16.8) oj (3.16.8)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.3)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.2.1)
jwt (>= 2.9.2)
oauth2 (~> 2.0)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8)
omniauth-microsoft_graph (2.1.0)
jwt (~> 2.0)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8.0)
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth-saml (2.2.4)
omniauth (~> 2.1)
ruby-saml (~> 1.18)
openssl (3.3.0) openssl (3.3.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
@ -388,6 +421,10 @@ GEM
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.16) rack (3.1.16)
rack-protection (4.1.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.0.0) rack-session (2.0.0)
@ -498,6 +535,9 @@ GEM
rubocop-rspec (3.3.0) rubocop-rspec (3.3.0)
rubocop (~> 1.61) rubocop (~> 1.61)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-saml (1.18.0)
nokogiri (>= 1.13.10)
rexml
ruby-vips (2.2.2) ruby-vips (2.2.2)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
@ -531,6 +571,9 @@ GEM
simplecov-html (0.13.1) simplecov-html (0.13.1)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
smart_properties (1.17.0) smart_properties (1.17.0)
snaky_hash (2.0.3)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
sqlite3 (2.5.0) sqlite3 (2.5.0)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
sqlite3 (2.5.0-aarch64-linux-gnu) sqlite3 (2.5.0-aarch64-linux-gnu)
@ -562,6 +605,7 @@ GEM
uniform_notifier (1.16.0) uniform_notifier (1.16.0)
uri (1.0.3) uri (1.0.3)
useragent (0.16.11) useragent (0.16.11)
version_gem (1.1.8)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.1) web-console (4.2.1)
@ -622,6 +666,11 @@ DEPENDENCIES
lograge lograge
mysql2 mysql2
oj oj
omniauth
omniauth-google-oauth2
omniauth-microsoft_graph
omniauth-rails_csrf_protection
omniauth-saml
pagy pagy
pg pg
premailer-rails premailer-rails

@ -0,0 +1,221 @@
# DocuSeal Omniauth Implementation Guide
This document describes the complete implementation of omniauth/SSO functionality in DocuSeal, restoring the features that were previously gated behind paywall checks.
## Overview
We have successfully implemented comprehensive omniauth support with the following providers:
- **SAML 2.0** - For enterprise SSO integration
- **Google OAuth2** - For Google account authentication
- **Microsoft Graph** - For Microsoft/Office 365 authentication
## What Was Implemented
### 1. Database Schema Changes
- Added `provider` and `uid` columns to the `users` table
- Created migration: `db/migrate/20250719104801_add_omniauth_to_users.rb`
- Added unique index on `[provider, uid]` combination
### 2. User Model Updates
- Added `:omniauthable` to Devise modules
- Configured omniauth providers: `%i[saml google_oauth2 microsoft_graph]`
- Implemented `User.from_omniauth(auth)` class method for handling authentication callbacks
- Added support for both single-tenant and multi-tenant account creation
### 3. Devise Configuration
- Updated `config/initializers/devise.rb` with omniauth provider configurations
- Added environment variable-based configuration for SAML
- Configured Google OAuth2 and Microsoft Graph providers
- Added proper attribute mapping for SAML assertions
### 4. Controllers
- Created `app/controllers/users/omniauth_callbacks_controller.rb`
- Handles callbacks for all three providers (SAML, Google, Microsoft)
- Includes proper error handling and session management
- Updated `app/controllers/sso_settings_controller.rb` with configuration management
### 5. Views and UI
- Replaced SSO paywall placeholder with functional SAML configuration form
- Updated login form to include all three authentication providers
- Added SSO settings interface for SAML configuration
- Removed `Docuseal.multitenant?` checks that were gating SSO features
### 6. Routes
- Updated routes to include omniauth callbacks
- Added update action to SSO settings controller
- Enabled SSO settings for all users (removed paywall restrictions)
### 7. Gemfile Dependencies
Added the following gems:
```ruby
gem 'omniauth'
gem 'omniauth-rails_csrf_protection'
gem 'omniauth-saml'
gem 'omniauth-google-oauth2'
gem 'omniauth-microsoft_graph'
```
### 8. Development Environment
- Created `Dockerfile.dev` for development with mounted codebase
- Created `docker-compose.dev.yml` for local development environment
- Configured development environment with PostgreSQL and Redis
## Configuration
### SAML Configuration
#### Environment Variables
Set these environment variables for SAML configuration:
```bash
SAML_IDP_SSO_SERVICE_URL=https://your-idp.com/sso/saml
SAML_IDP_CERT_FINGERPRINT=AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD
SAML_SP_ENTITY_ID=docuseal
APP_URL=http://localhost:3000
```
#### Database Configuration
Alternatively, configure SAML through the web interface at `/settings/sso` which stores encrypted configuration in the database.
#### Service Provider URLs
Provide these URLs to your Identity Provider:
- **Assertion Consumer Service URL**: `http://localhost:3000/users/auth/saml/callback`
- **SP Metadata URL**: `http://localhost:3000/users/auth/saml/metadata`
- **SP Entity ID**: `docuseal` (or your custom value)
### Google OAuth2 Configuration
Add to Rails credentials or environment variables:
```bash
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
```
### Microsoft Graph Configuration
Add to Rails credentials or environment variables:
```bash
MICROSOFT_CLIENT_ID=your-microsoft-client-id
MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret
```
## Usage
### For Users
1. Navigate to the login page
2. Choose from available authentication methods:
- Sign in with Google
- Sign in with Microsoft
- Sign in with SAML SSO (if configured)
3. Complete authentication with your chosen provider
4. You'll be automatically signed in or prompted to complete registration
### For Administrators
1. Go to Settings → SSO
2. Configure SAML settings with your Identity Provider details
3. Test the configuration using the "Test SAML Login" button
4. Users can now authenticate using the configured SSO provider
## Development Setup
### Using Docker (Recommended)
```bash
# Build the development environment
docker-compose -f docker-compose.dev.yml build
# Start the development environment
docker-compose -f docker-compose.dev.yml up
# Access the application at http://localhost:3000
```
### Local Development
```bash
# Install dependencies
bundle install
yarn install
# Run database migrations
rails db:create db:migrate
# Start the development server
rails server
```
## Testing
### SAML Testing
1. Set up environment variables for SAML configuration
2. Navigate to `/settings/sso` to configure SAML
3. Use the "Test SAML Login" button to verify configuration
4. Check logs for any authentication errors
### OAuth Testing
1. Configure Google/Microsoft credentials
2. Navigate to login page
3. Click "Sign in with Google" or "Sign in with Microsoft"
4. Complete OAuth flow and verify user creation/authentication
## Security Considerations
1. **CSRF Protection**: Implemented via `omniauth-rails_csrf_protection` gem
2. **Secure Credentials**: Store sensitive configuration in Rails credentials or environment variables
3. **Certificate Validation**: SAML certificate fingerprints are validated
4. **Session Management**: Proper session cleanup and management implemented
## Troubleshooting
### Common Issues
1. **"NameError: uninitialized constant EncryptedConfig"**
- This was resolved by moving SAML configuration to environment variables
- Ensure proper initialization order in Devise configuration
2. **"Invalid credentials" errors**
- Verify OAuth client IDs and secrets are correct
- Check redirect URIs match exactly
3. **SAML authentication failures**
- Verify IdP certificate fingerprint is correct
- Check that assertion consumer service URL matches
- Ensure name identifier format matches IdP configuration
### Logs
Check application logs for detailed error messages:
```bash
docker-compose -f docker-compose.dev.yml logs app
```
## Files Modified/Created
### New Files
- `db/migrate/20250719104801_add_omniauth_to_users.rb`
- `app/controllers/users/omniauth_callbacks_controller.rb`
- `app/views/sso_settings/_saml_form.html.erb`
- `Dockerfile.dev`
- `docker-compose.dev.yml`
- `OMNIAUTH_IMPLEMENTATION.md`
### Modified Files
- `Gemfile` - Added omniauth gems
- `app/models/user.rb` - Added omniauthable and from_omniauth method
- `config/initializers/devise.rb` - Added omniauth provider configurations
- `app/controllers/sso_settings_controller.rb` - Added update method
- `app/views/sso_settings/index.html.erb` - Replaced placeholder with form
- `app/views/devise/sessions/new.html.erb` - Added omniauth provider buttons
- `app/views/shared/_settings_nav.html.erb` - Removed paywall check
- `config/routes.rb` - Added update route for SSO settings
## Next Steps
1. **Production Deployment**: Configure production environment variables
2. **Additional Providers**: Add more omniauth providers as needed
3. **Advanced SAML**: Implement IdP-initiated SSO and SLO (Single Logout)
4. **User Management**: Add admin interface for managing SSO users
5. **Audit Logging**: Add logging for SSO authentication events
## Support
For issues or questions about this implementation, refer to:
- [Devise Omniauth Documentation](https://github.com/heartcombo/devise/wiki/OmniAuth:-Overview)
- [Omniauth SAML Documentation](https://github.com/omniauth/omniauth-saml)
- DocuSeal application logs and error messages

@ -2,9 +2,53 @@
class SsoSettingsController < ApplicationController class SsoSettingsController < ApplicationController
before_action :load_encrypted_config before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index authorize_resource :encrypted_config, only: [:show, :update]
def index; end def show; end
def update
saml_config = params[:saml_config] || {}
# Handle IdP metadata file upload and parsing
if params[:idp_metadata_file].present?
begin
parsed_config = parse_idp_metadata(params[:idp_metadata_file])
saml_config.merge!(parsed_config)
# Save the parsed configuration immediately
@encrypted_config.value = saml_config.to_json
if @encrypted_config.save
redirect_to settings_sso_path, notice: 'IdP metadata parsed and saved successfully!'
else
redirect_to settings_sso_path, alert: 'Failed to save parsed configuration. Please try again.'
end
return
rescue StandardError => e
Rails.logger.error "Failed to parse IdP metadata: #{e.message}"
redirect_to settings_sso_path, alert: "Failed to parse IdP metadata: #{e.message}"
return
end
end
# Validate required fields for manual configuration
if saml_config['idp_sso_service_url'].blank? || saml_config['idp_cert_fingerprint'].blank?
redirect_to settings_sso_path, alert: 'Please fill in all required SAML configuration fields.'
return
end
# Save the SAML configuration
@encrypted_config.value = saml_config.to_json
if @encrypted_config.save
redirect_to settings_sso_path, notice: 'SAML configuration saved successfully.'
else
redirect_to settings_sso_path, alert: 'Failed to save SAML configuration. Please try again.'
end
rescue StandardError => e
Rails.logger.error "Failed to save SAML config: #{e.message}"
redirect_to settings_sso_path, alert: 'An error occurred while saving the configuration.'
end
private private
@ -12,4 +56,77 @@ class SsoSettingsController < ApplicationController
@encrypted_config = @encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'saml_configs') EncryptedConfig.find_or_initialize_by(account: current_account, key: 'saml_configs')
end end
def parse_idp_metadata(metadata_file)
require 'nokogiri'
# Read and parse the XML metadata file
xml_content = metadata_file.read
doc = Nokogiri::XML(xml_content)
# Remove default namespace to make XPath queries simpler
doc.remove_namespaces!
config = {}
# Extract Entity ID
entity_descriptor = doc.at_xpath('//EntityDescriptor')
config['idp_entity_id'] = entity_descriptor['entityID'] if entity_descriptor
# Try SAML 2.0 SSO Service URL (Azure AD puts this in IDPSSODescriptor)
sso_service = doc.at_xpath('//IDPSSODescriptor/SingleSignOnService[@Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"]')
sso_service ||= doc.at_xpath('//IDPSSODescriptor/SingleSignOnService[@Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"]')
if sso_service
config['idp_sso_service_url'] = sso_service['Location']
else
# Fallback: Try WS-Federation endpoints and convert to SAML
wsfed_endpoint = doc.at_xpath('//SecurityTokenServiceEndpoint/EndpointReference/Address')
wsfed_endpoint ||= doc.at_xpath('//PassiveRequestorEndpoint/EndpointReference/Address')
if wsfed_endpoint
# Convert WS-Fed endpoint to SAML endpoint (Azure AD pattern)
wsfed_url = wsfed_endpoint.text
config['idp_sso_service_url'] = wsfed_url.gsub('/wsfed', '/saml2')
end
end
# Extract SLO Service URL
slo_service = doc.at_xpath('//IDPSSODescriptor/SingleLogoutService[@Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"]')
slo_service ||= doc.at_xpath('//IDPSSODescriptor/SingleLogoutService[@Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"]')
config['idp_slo_service_url'] = slo_service['Location'] if slo_service
# Extract certificate and calculate fingerprint (try multiple locations)
cert_element = doc.at_xpath('//IDPSSODescriptor/KeyDescriptor[@use="signing"]/KeyInfo/X509Data/X509Certificate')
cert_element ||= doc.at_xpath('//IDPSSODescriptor/KeyDescriptor/KeyInfo/X509Data/X509Certificate')
cert_element ||= doc.at_xpath('//KeyDescriptor[@use="signing"]/KeyInfo/X509Data/X509Certificate')
cert_element ||= doc.at_xpath('//KeyDescriptor/KeyInfo/X509Data/X509Certificate')
cert_element ||= doc.at_xpath('//X509Certificate')
if cert_element
cert_data = cert_element.text.gsub(/\s+/, '')
cert_der = Base64.decode64(cert_data)
fingerprint = Digest::SHA1.hexdigest(cert_der).upcase.scan(/../).join(':')
config['idp_cert_fingerprint'] = fingerprint
end
# Extract Name ID formats
name_id_format = doc.at_xpath('//IDPSSODescriptor/NameIDFormat')
config['name_identifier_format'] = name_id_format.text if name_id_format
# Set default name identifier format if not found
config['name_identifier_format'] ||= 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
# Validation
if config['idp_sso_service_url'].blank?
raise 'No SSO service URL found in metadata. Please ensure this is a valid SAML 2.0 or Azure AD metadata file.'
end
if config['idp_cert_fingerprint'].blank?
raise 'No certificate found in metadata. Please ensure the metadata contains a valid X.509 certificate.'
end
config
rescue Nokogiri::XML::SyntaxError => e
raise "Invalid XML metadata: #{e.message}"
end
end end

@ -0,0 +1,141 @@
# frozen_string_literal: true
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
# See https://github.com/omniauth/omniauth/wiki/FAQ#rails-session-is-clobbered-after-callback-on-developer-strategy
skip_before_action :verify_authenticity_token, only: [:saml, :google_oauth2, :microsoft_graph]
def saml
handle_omniauth_callback('SAML')
end
def google_oauth2
handle_omniauth_callback('Google')
end
def microsoft_graph
handle_omniauth_callback('Microsoft')
end
def failure
Rails.logger.error "Omniauth failure: #{params[:message]}"
redirect_to new_user_session_path, alert: "Authentication failed: #{params[:message]}"
end
def passthru
# Handle requests to unconfigured or improperly configured SSO providers
provider = params[:provider] || request.env['omniauth.strategy']&.name&.to_s
case provider
when 'saml'
# Check if SAML is properly configured anywhere in the system
saml_configured = false
# Check environment variables first
if ENV['SAML_IDP_SSO_SERVICE_URL'].present? && ENV['SAML_IDP_CERT_FINGERPRINT'].present?
saml_configured = true
else
# Check if any account has SAML configured in database
saml_config_record = EncryptedConfig.find_by(key: 'saml_configs')
if saml_config_record&.value.present?
begin
config = JSON.parse(saml_config_record.value)
saml_configured = config['idp_sso_service_url'].present? && config['idp_cert_fingerprint'].present?
rescue JSON::ParserError
# Invalid JSON, treat as not configured
end
end
end
unless saml_configured
redirect_to new_user_session_path, alert: 'SAML SSO is not configured. Please contact your administrator to set up SAML authentication.'
return
end
when 'google_oauth2'
if Rails.application.credentials.google_client_id.blank? || Rails.application.credentials.google_client_id == 'placeholder_client_id'
redirect_to new_user_session_path, alert: 'Google OAuth is not configured. Please contact your administrator to set up Google authentication.'
return
end
when 'microsoft_graph'
if Rails.application.credentials.microsoft_client_id.blank? || Rails.application.credentials.microsoft_client_id == 'placeholder_client_id'
redirect_to new_user_session_path, alert: 'Microsoft OAuth is not configured. Please contact your administrator to set up Microsoft authentication.'
return
end
end
# If we get here, redirect to configuration page
provider_name = provider&.humanize || 'SSO'
redirect_to new_user_session_path, alert: "#{provider_name} authentication is not available. Please contact your administrator."
end
def metadata
# Generate basic SAML metadata for the service provider
entity_id = ENV.fetch('SAML_SP_ENTITY_ID', 'docuseal')
app_url = ENV.fetch('APP_URL', 'http://localhost:3000')
callback_url = "#{app_url}/auth/saml/callback"
# Build comprehensive SP metadata XML
logout_url = "#{app_url}/sign_out"
metadata_xml = <<~XML
<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="#{entity_id}">
<md:SPSSODescriptor
AuthnRequestsSigned="false"
WantAssertionsSigned="true"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<!-- Name ID Format -->
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<!-- Assertion Consumer Services -->
<md:AssertionConsumerService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="#{callback_url}"
index="0"
isDefault="true" />
<md:AssertionConsumerService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="#{callback_url}"
index="1" />
<!-- Single Logout Service -->
<md:SingleLogoutService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="#{logout_url}" />
<md:SingleLogoutService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="#{logout_url}" />
</md:SPSSODescriptor>
</md:EntityDescriptor>
XML
response.headers['Content-Disposition'] = 'attachment; filename="saml-metadata.xml"'
render xml: metadata_xml, content_type: 'application/samlmetadata+xml'
end
private
def handle_omniauth_callback(provider_name)
@user = User.from_omniauth(request.env['omniauth.auth'])
if @user&.persisted?
sign_in_and_redirect @user, event: :authentication
set_flash_message(:notice, :success, kind: provider_name) if is_navigational_format?
else
# Store the omniauth data in session for potential account linking
session['devise.omniauth_data'] = request.env['omniauth.auth'].except(:extra)
# Redirect to registration with error message
redirect_to new_user_registration_url,
alert: "There was an issue with your #{provider_name} account. Please try again or contact support."
end
rescue StandardError => e
Rails.logger.error "Omniauth callback error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
redirect_to new_user_session_path,
alert: "Authentication failed. Please try again or contact support."
end
end

@ -13,7 +13,7 @@
# Indexes # Indexes
# #
# index_document_generation_events_on_submitter_id (submitter_id) # index_document_generation_events_on_submitter_id (submitter_id)
# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[])) # index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))
# #
# Foreign Keys # Foreign Keys
# #

@ -20,7 +20,7 @@
# #
# index_email_events_on_account_id_and_event_datetime (account_id,event_datetime) # index_email_events_on_account_id_and_event_datetime (account_id,event_datetime)
# index_email_events_on_email (email) # index_email_events_on_email (email)
# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[])) # index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text]))
# index_email_events_on_emailable (emailable_type,emailable_id) # index_email_events_on_emailable (emailable_type,emailable_id)
# index_email_events_on_message_id (message_id) # index_email_events_on_message_id (message_id)
# #

@ -10,7 +10,7 @@
# name :text # name :text
# preferences :text not null # preferences :text not null
# slug :string not null # slug :string not null
# source :text not null # source :string not null
# submitters_order :string not null # submitters_order :string not null
# template_fields :text # template_fields :text
# template_schema :text # template_schema :text

@ -19,13 +19,15 @@
# locked_at :datetime # locked_at :datetime
# otp_required_for_login :boolean default(FALSE), not null # otp_required_for_login :boolean default(FALSE), not null
# otp_secret :string # otp_secret :string
# provider :string
# remember_created_at :datetime # remember_created_at :datetime
# reset_password_sent_at :datetime # reset_password_sent_at :datetime
# reset_password_token :string # reset_password_token :string
# role :string not null # role :string not null
# sign_in_count :integer default(0), not null # sign_in_count :integer default(0), not null
# uid :string
# unlock_token :string # unlock_token :string
# uuid :text not null # uuid :string not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :bigint not null # account_id :bigint not null
@ -34,6 +36,7 @@
# #
# index_users_on_account_id (account_id) # index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE # index_users_on_email (email) UNIQUE
# index_users_on_provider_and_uid (provider,uid) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE # index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_unlock_token (unlock_token) UNIQUE # index_users_on_unlock_token (unlock_token) UNIQUE
# index_users_on_uuid (uuid) UNIQUE # index_users_on_uuid (uuid) UNIQUE
@ -64,7 +67,7 @@ class User < ApplicationRecord
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig' has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
has_many :email_messages, dependent: :destroy, foreign_key: :author_id, inverse_of: :author has_many :email_messages, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable, :lockable devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable, :lockable, :omniauthable, omniauth_providers: %i[saml google_oauth2 microsoft_graph]
attribute :role, :string, default: ADMIN_ROLE attribute :role, :string, default: ADMIN_ROLE
attribute :uuid, :string, default: -> { SecureRandom.uuid } attribute :uuid, :string, default: -> { SecureRandom.uuid }
@ -116,4 +119,56 @@ class User < ApplicationRecord
email email
end end
end end
# Omniauth callback method
def self.from_omniauth(auth)
# Find existing user by provider and uid
user = User.find_by(provider: auth.provider, uid: auth.uid)
if user
# Update user info from provider if needed
user.update(
email: auth.info.email,
first_name: auth.info.first_name || auth.info.name&.split&.first,
last_name: auth.info.last_name || auth.info.name&.split&.last
) if auth.info.email.present?
return user
end
# Try to find user by email if no provider/uid match
existing_user = User.find_by(email: auth.info.email) if auth.info.email.present?
if existing_user
# Link existing account with omniauth provider
existing_user.update(
provider: auth.provider,
uid: auth.uid
)
return existing_user
end
# Create new user from omniauth data
# For multitenant setups, we need to determine the account
account = if Docuseal.multitenant?
# In multitenant mode, create account from domain or use default logic
Account.find_or_create_by(name: auth.info.email&.split('@')&.last || 'SSO Account')
else
# In single-tenant mode, use the first account or create one
Account.first || Account.create!(name: 'Default Account')
end
User.create!(
email: auth.info.email,
first_name: auth.info.first_name || auth.info.name&.split&.first,
last_name: auth.info.last_name || auth.info.name&.split&.last,
provider: auth.provider,
uid: auth.uid,
account: account,
# Skip password validation for omniauth users
password: Devise.friendly_token[0, 20]
)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "Failed to create user from omniauth: #{e.message}"
nil
end
end end

@ -0,0 +1,52 @@
# frozen_string_literal: true
class SamlConfigService
def self.load_config(account = nil)
# Try to load from database first
if account
config_record = EncryptedConfig.find_by(account: account, key: 'saml_configs')
if config_record&.value.present?
return JSON.parse(config_record.value).with_indifferent_access
end
end
# Fall back to environment variables
{
idp_sso_service_url: ENV['SAML_IDP_SSO_SERVICE_URL'],
idp_cert_fingerprint: ENV['SAML_IDP_CERT_FINGERPRINT'],
sp_entity_id: ENV.fetch('SAML_SP_ENTITY_ID', 'docuseal'),
name_identifier_format: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
email_attribute: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
first_name_attribute: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
last_name_attribute: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
name_attribute: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'
}.with_indifferent_access
rescue => e
Rails.logger.warn "Could not load SAML config: #{e.message}"
{}
end
def self.configured?(account = nil)
config = load_config(account)
config[:idp_sso_service_url].present? && config[:idp_cert_fingerprint].present?
end
def self.omniauth_config(account = nil)
config = load_config(account)
return nil unless configured?(account)
{
assertion_consumer_service_url: "#{ENV.fetch('APP_URL', 'http://localhost:3000')}/auth/saml/callback",
sp_entity_id: config[:sp_entity_id],
idp_sso_service_url: config[:idp_sso_service_url],
idp_cert_fingerprint: config[:idp_cert_fingerprint],
name_identifier_format: config[:name_identifier_format],
attribute_statements: {
email: [config[:email_attribute]],
first_name: [config[:first_name_attribute]],
last_name: [config[:last_name_attribute]],
name: [config[:name_attribute]]
}
}
end
end

@ -20,20 +20,44 @@
<% end %> <% end %>
<% if devise_mapping.omniauthable? %> <% if devise_mapping.omniauthable? %>
<div class="space-y-4"> <div class="space-y-4">
<% if User.omniauth_providers.include?(:google_oauth2) %> <% if User.omniauth_providers.include?(:google_oauth2) && Rails.application.credentials.google_client_id.present? && Rails.application.credentials.google_client_id != 'placeholder_client_id' %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %> <%= form_for '', url: '/auth/google_oauth2', data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone> <set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %> <%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: t('sign_in_with_google'), icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %> <%= f.button button_title(title: t('sign_in_with_google'), icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %> <% end %>
<% end %> <% end %>
<% if User.omniauth_providers.include?(:microsoft_office365) %> <% if User.omniauth_providers.include?(:microsoft_graph) && Rails.application.credentials.microsoft_client_id.present? && Rails.application.credentials.microsoft_client_id != 'placeholder_client_id' %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %> <%= form_for '', url: '/auth/microsoft_graph', data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone> <set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %> <%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: t('sign_in_with_microsoft'), icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %> <%= f.button button_title(title: t('sign_in_with_microsoft'), icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% end %> <% end %>
<% end %> <% end %>
<%
# Check if SAML is configured (either in ENV or database)
saml_configured = false
if ENV['SAML_IDP_SSO_SERVICE_URL'].present? && ENV['SAML_IDP_CERT_FINGERPRINT'].present?
saml_configured = true
else
saml_config_record = EncryptedConfig.find_by(key: 'saml_configs')
if saml_config_record&.value.present?
begin
config = JSON.parse(saml_config_record.value)
saml_configured = config['idp_sso_service_url'].present? && config['idp_cert_fingerprint'].present?
rescue JSON::ParserError
# Invalid JSON, treat as not configured
end
end
end
%>
<% if User.omniauth_providers.include?(:saml) && saml_configured %>
<%= form_for '', url: '/auth/saml', data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_saml" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_saml' %>
<%= f.button button_title(title: 'Sign in with SAML SSO', icon: svg_icon('certificate', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% end %>
<% end %>
</div> </div>
<% end %> <% end %>
<%= render 'extra_links' %> <%= render 'extra_links' %>

@ -83,9 +83,9 @@
<% end %> <% end %>
</li> </li>
<% end %> <% end %>
<% if (!Docuseal.multitenant? || can?(:manage, :saml_sso)) && can?(:read, EncryptedConfig.new(key: 'saml_configs', account: current_account)) && true_user == current_user %> <% if can?(:read, EncryptedConfig.new(key: 'saml_configs', account: current_account)) && true_user == current_user %>
<li> <li>
<%= link_to 'SSO', settings_sso_index_path, class: 'text-base hover:bg-base-300' %> <%= link_to 'SSO', settings_sso_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% end %> <% end %>
<%= render 'shared/settings_nav_extra2' %> <%= render 'shared/settings_nav_extra2' %>

@ -0,0 +1,160 @@
<%= form_with model: @encrypted_config, url: settings_sso_path, method: :patch, local: true, multipart: true, class: "space-y-4" do |f| %>
<div class="space-y-6">
<div class="alert alert-info">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">SAML SSO Configuration</p>
<p class="text-gray-700">
Configure SAML 2.0 Single Sign-On for your organization. Users will be able to sign in using your identity provider.
</p>
</div>
</div>
<% saml_config = @encrypted_config.value.present? ? JSON.parse(@encrypted_config.value) : {} %>
<!-- IdP Metadata Upload Section -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h3 class="card-title text-lg">Quick Setup: Upload IdP Metadata</h3>
<p class="text-sm text-gray-600 mb-4">
Upload your Identity Provider's metadata XML file to automatically populate the configuration below.
</p>
<div class="form-control">
<%= label_tag 'idp_metadata_file', 'IdP Metadata XML File', class: 'label' %>
<%= file_field_tag 'idp_metadata_file',
accept: '.xml,application/xml,text/xml',
class: 'file-input file-input-bordered w-full' %>
<div class="label">
<span class="label-text-alt">Select your IdP's metadata.xml file to auto-configure SAML settings</span>
</div>
</div>
<div class="card-actions justify-end mt-4">
<%= submit_tag 'Parse Metadata', class: 'btn btn-primary btn-sm' %>
</div>
</div>
</div>
<div class="divider">OR configure manually</div>
<div class="form-control">
<%= label_tag 'saml_config[idp_sso_service_url]', 'Identity Provider SSO URL', class: 'label' %>
<%= text_field_tag 'saml_config[idp_sso_service_url]', saml_config['idp_sso_service_url'],
class: 'input input-bordered w-full',
placeholder: 'https://your-idp.com/sso/saml',
required: true %>
<div class="label">
<span class="label-text-alt">The URL where users will be redirected to authenticate</span>
</div>
</div>
<div class="form-control">
<%= label_tag 'saml_config[idp_cert_fingerprint]', 'Identity Provider Certificate Fingerprint', class: 'label' %>
<%= text_field_tag 'saml_config[idp_cert_fingerprint]', saml_config['idp_cert_fingerprint'],
class: 'input input-bordered w-full',
placeholder: 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD',
required: true %>
<div class="label">
<span class="label-text-alt">SHA1 fingerprint of your IdP's certificate</span>
</div>
</div>
<div class="form-control">
<%= label_tag 'saml_config[sp_entity_id]', 'Service Provider Entity ID', class: 'label' %>
<%= text_field_tag 'saml_config[sp_entity_id]', saml_config['sp_entity_id'] || 'docuseal',
class: 'input input-bordered w-full',
placeholder: 'docuseal',
required: true %>
<div class="label">
<span class="label-text-alt">Unique identifier for this DocuSeal instance</span>
</div>
</div>
<div class="form-control">
<%= label_tag 'saml_config[name_identifier_format]', 'Name ID Format', class: 'label' %>
<%= select_tag 'saml_config[name_identifier_format]',
options_for_select([
['Email Address', 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'],
['Persistent', 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'],
['Transient', 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient']
], saml_config['name_identifier_format'] || 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'),
class: 'select select-bordered w-full' %>
<div class="label">
<span class="label-text-alt">Format for the user identifier sent by your IdP</span>
</div>
</div>
<div class="divider">Attribute Mapping</div>
<div class="form-control">
<%= label_tag 'saml_config[email_attribute]', 'Email Attribute', class: 'label' %>
<%= text_field_tag 'saml_config[email_attribute]',
saml_config['email_attribute'] || 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
class: 'input input-bordered w-full',
placeholder: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' %>
<div class="label">
<span class="label-text-alt">SAML attribute name that contains the user's email</span>
</div>
</div>
<div class="form-control">
<%= label_tag 'saml_config[first_name_attribute]', 'First Name Attribute', class: 'label' %>
<%= text_field_tag 'saml_config[first_name_attribute]',
saml_config['first_name_attribute'] || 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
class: 'input input-bordered w-full',
placeholder: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname' %>
<div class="label">
<span class="label-text-alt">SAML attribute name that contains the user's first name</span>
</div>
</div>
<div class="form-control">
<%= label_tag 'saml_config[last_name_attribute]', 'Last Name Attribute', class: 'label' %>
<%= text_field_tag 'saml_config[last_name_attribute]',
saml_config['last_name_attribute'] || 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
class: 'input input-bordered w-full',
placeholder: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname' %>
<div class="label">
<span class="label-text-alt">SAML attribute name that contains the user's last name</span>
</div>
</div>
<div class="divider">Service Provider Information</div>
<div class="alert">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">Configuration URLs for your Identity Provider</p>
<div class="mt-2 space-y-1 text-sm">
<p><strong>Assertion Consumer Service URL:</strong></p>
<code class="bg-base-200 px-2 py-1 rounded text-xs"><%= "#{request.base_url}/auth/saml/callback" %></code>
<p class="mt-2"><strong>SP Metadata URL:</strong></p>
<div class="flex items-center gap-2">
<code class="bg-base-200 px-2 py-1 rounded text-xs flex-1"><%= "#{request.base_url}/auth/saml/metadata" %></code>
<a href="<%= "#{request.base_url}/auth/saml/metadata" %>"
class="btn btn-sm btn-outline"
target="_blank"
title="Download SAML Metadata">
Download
</a>
</div>
<p class="mt-2"><strong>SP Entity ID:</strong></p>
<code class="bg-base-200 px-2 py-1 rounded text-xs"><%= saml_config['sp_entity_id'] || 'docuseal' %></code>
</div>
</div>
</div>
<div class="flex gap-2">
<%= f.submit 'Save SAML Configuration', class: 'btn btn-primary' %>
<% if @encrypted_config.persisted? && @encrypted_config.value.present? %>
<%= link_to 'Test SAML Login', user_saml_omniauth_authorize_path,
method: :post,
class: 'btn btn-outline',
data: { turbo: false } %>
<% end %>
</div>
</div>
<% end %>

@ -2,7 +2,7 @@
<%= render 'shared/settings_nav' %> <%= render 'shared/settings_nav' %>
<div class="flex-grow max-w-xl mx-auto"> <div class="flex-grow max-w-xl mx-auto">
<h1 class="text-4xl font-bold mb-4">SAML SSO</h1> <h1 class="text-4xl font-bold mb-4">SAML SSO</h1>
<%= render 'placeholder' %> <%= render 'saml_form' %>
</div> </div>
<div class="w-0 md:w-52"></div> <div class="w-0 md:w-52"></div>
</div> </div>

@ -330,5 +330,84 @@ Devise.setup do |config|
# changed. Defaults to true, so a user is signed in automatically after changing a password. # changed. Defaults to true, so a user is signed in automatically after changing a password.
# config.sign_in_after_change_password = true # config.sign_in_after_change_password = true
# ==> Omniauth
# Add a new line here for each provider that your app supports.
# SAML Configuration
# Dynamic configuration that loads from database or environment at runtime
config.omniauth :saml,
setup: lambda { |env|
begin
# Load SAML configuration dynamically for each request
request = ActionDispatch::Request.new(env)
# Try to get account context from session or other means
account = nil
if request.session && request.session['warden.user.user.key']
user_id = request.session['warden.user.user.key'][0][0]
user = User.find_by(id: user_id) if user_id
account = user&.account
end
# If no account from session, try to get any account with SAML config
unless account
saml_config_record = EncryptedConfig.find_by(key: 'saml_configs')
account = saml_config_record&.account if saml_config_record
end
# Load SAML config from database or environment
saml_config = SamlConfigService.load_config(account)
Rails.logger.info "SAML Setup: Account=#{account&.id}, SSO_URL=#{saml_config[:idp_sso_service_url]}, Cert=#{saml_config[:idp_cert_fingerprint].present?}"
# Set the omniauth strategy options
env['omniauth.strategy'].options.merge!({
assertion_consumer_service_url: "#{ENV.fetch('APP_URL', 'http://localhost:3000')}/auth/saml/callback",
sp_entity_id: saml_config[:sp_entity_id] || ENV.fetch('SAML_SP_ENTITY_ID', 'docuseal'),
idp_sso_service_url: saml_config[:idp_sso_service_url] || ENV['SAML_IDP_SSO_SERVICE_URL'],
idp_cert_fingerprint: saml_config[:idp_cert_fingerprint] || ENV['SAML_IDP_CERT_FINGERPRINT'],
name_identifier_format: saml_config[:name_identifier_format] || 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
attribute_statements: {
email: [saml_config[:email_attribute] || 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'],
first_name: [saml_config[:first_name_attribute] || 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'],
last_name: [saml_config[:last_name_attribute] || 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'],
name: [saml_config[:name_attribute] || 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name']
}
})
# If no valid configuration is found, set error
unless saml_config[:idp_sso_service_url].present? && saml_config[:idp_cert_fingerprint].present?
Rails.logger.error "SAML Setup Failed: Missing SSO URL or certificate"
env['omniauth.error'] = 'SAML is not configured'
env['omniauth.error.type'] = :setup_failed
end
rescue => e
Rails.logger.error "SAML Setup Error: #{e.message}"
env['omniauth.error'] = "SAML setup failed: #{e.message}"
env['omniauth.error.type'] = :setup_failed
end
}
# Google OAuth2 Configuration
# Always configure (will use placeholder if credentials not set)
config.omniauth :google_oauth2,
Rails.application.credentials.google_client_id || 'placeholder_client_id',
Rails.application.credentials.google_client_secret || 'placeholder_client_secret',
{
scope: 'email,profile',
prompt: 'select_account',
image_aspect_ratio: 'square',
image_size: 50
}
# Microsoft Graph Configuration
# Always configure (will use placeholder if credentials not set)
config.omniauth :microsoft_graph,
Rails.application.credentials.microsoft_client_id || 'placeholder_client_id',
Rails.application.credentials.microsoft_client_secret || 'placeholder_client_secret',
{
scope: 'openid email profile'
}
ActiveSupport.run_load_hooks(:devise_config, config) ActiveSupport.run_load_hooks(:devise_config, config)
end end

@ -16,13 +16,15 @@ Rails.application.routes.draw do
devise_for :users, devise_for :users,
path: '/', only: %i[sessions passwords omniauth_callbacks], path: '/', only: %i[sessions passwords omniauth_callbacks],
controllers: begin controllers: {
options = { sessions: 'sessions', passwords: 'passwords' } sessions: 'sessions',
options[:omniauth_callbacks] = 'omniauth_callbacks' if User.devise_modules.include?(:omniauthable) passwords: 'passwords',
options omniauth_callbacks: 'users/omniauth_callbacks'
end }
devise_scope :user do devise_scope :user do
# SAML metadata endpoint
match '/auth/saml/metadata', to: 'users/omniauth_callbacks#metadata', via: [:get, :head]
resource :invitation, only: %i[update] do resource :invitation, only: %i[update] do
get '' => :edit get '' => :edit
end end
@ -168,7 +170,7 @@ Rails.application.routes.draw do
resources :sms, only: %i[index], controller: 'sms_settings' resources :sms, only: %i[index], controller: 'sms_settings'
end end
resources :email, only: %i[index create], controller: 'email_smtp_settings' resources :email, only: %i[index create], controller: 'email_smtp_settings'
resources :sso, only: %i[index], controller: 'sso_settings' resource :sso, only: %i[show update], controller: 'sso_settings'
resources :notifications, only: %i[index create], controller: 'notifications_settings' resources :notifications, only: %i[index create], controller: 'notifications_settings'
resource :esign, only: %i[show create new update destroy], controller: 'esign_settings' resource :esign, only: %i[show create new update destroy], controller: 'esign_settings'
resources :users, only: %i[index] resources :users, only: %i[index]

@ -0,0 +1,10 @@
# frozen_string_literal: true
class AddOmniauthToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :provider, :string
add_column :users, :uid, :string
add_index :users, [:provider, :uid], unique: true
end
end

@ -10,10 +10,10 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_06_27_130628) do ActiveRecord::Schema[8.0].define(version: 2025_07_19_104801) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "plpgsql" enable_extension "pg_catalog.plpgsql"
create_table "access_tokens", force: :cascade do |t| create_table "access_tokens", force: :cascade do |t|
t.bigint "user_id", null: false t.bigint "user_id", null: false
@ -160,7 +160,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_130628) do
t.string "event_name", null: false t.string "event_name", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["submitter_id", "event_name"], name: "index_document_generation_events_on_submitter_id_and_event_name", unique: true, where: "((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[]))" t.index ["submitter_id", "event_name"], name: "index_document_generation_events_on_submitter_id_and_event_name", unique: true, where: "((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))"
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id" t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
end end
@ -177,7 +177,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_130628) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.index ["account_id", "event_datetime"], name: "index_email_events_on_account_id_and_event_datetime" t.index ["account_id", "event_datetime"], name: "index_email_events_on_account_id_and_event_datetime"
t.index ["email"], name: "index_email_events_on_email" t.index ["email"], name: "index_email_events_on_email"
t.index ["email"], name: "index_email_events_on_email_event_types", where: "((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[]))" t.index ["email"], name: "index_email_events_on_email_event_types", where: "((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text]))"
t.index ["emailable_type", "emailable_id"], name: "index_email_events_on_emailable" t.index ["emailable_type", "emailable_id"], name: "index_email_events_on_emailable"
t.index ["message_id"], name: "index_email_events_on_message_id" t.index ["message_id"], name: "index_email_events_on_message_id"
end end
@ -430,8 +430,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_130628) do
t.string "otp_secret" t.string "otp_secret"
t.integer "consumed_timestep" t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false, null: false t.boolean "otp_required_for_login", default: false, null: false
t.string "provider"
t.string "uid"
t.index ["account_id"], name: "index_users_on_account_id" t.index ["account_id"], name: "index_users_on_account_id"
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true
t.index ["uuid"], name: "index_users_on_uuid", unique: true t.index ["uuid"], name: "index_users_on_uuid", unique: true

@ -0,0 +1,89 @@
services:
app:
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
build:
context: .
dockerfile: Dockerfile.dev
tty: true
stdin_open: true
ports:
- 3000:3000
volumes:
# Mount the entire codebase for live development
- .:/app
# Preserve node_modules and bundle cache
- /app/node_modules
- bundle_cache:/usr/local/bundle
# Mount data directory
- ./docuseal:/data/docuseal
environment:
- RAILS_ENV=development
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/docuseal_development
- REDIS_URL=redis://redis:6379/0
- FORCE_SSL=false
- SECRET_KEY_BASE=development_secret_key_base_change_in_production
command: >
sh -c "
bundle install &&
yarn install &&
bundle exec rails db:create db:migrate &&
rm -f /app/tmp/pids/server.pid &&
bundle exec rails server -b 0.0.0.0
"
postgres:
image: postgres:15
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: docuseal_development
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
# Optional: Sidekiq for background jobs
sidekiq:
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/app
- /app/node_modules
- bundle_cache:/usr/local/bundle
environment:
- RAILS_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/docuseal_development
- REDIS_URL=redis://redis:6379/0
command: >
sh -c "
bundle install &&
bundle exec sidekiq
"
volumes:
postgres_data:
redis_data:
bundle_cache:
Loading…
Cancel
Save