mirror of https://github.com/docusealco/docuseal
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"]
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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 %>
|
||||||
@ -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
|
||||||
@ -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…
Reference in new issue