From f2f17455269ef0e43b202e7c145f08fd0de170c7 Mon Sep 17 00:00:00 2001 From: Adam Hesch Date: Sat, 19 Jul 2025 15:29:27 -0500 Subject: [PATCH] feat: implement SSO authentication with SAML, Google, and Microsoft providers --- Dockerfile.dev | 91 ++++++++ Gemfile | 5 + Gemfile.lock | 49 ++++ OMNIAUTH_IMPLEMENTATION.md | 221 ++++++++++++++++++ app/controllers/sso_settings_controller.rb | 121 +++++++++- .../users/omniauth_callbacks_controller.rb | 141 +++++++++++ app/models/document_generation_event.rb | 2 +- app/models/email_event.rb | 2 +- app/models/submission.rb | 2 +- app/models/user.rb | 59 ++++- app/services/saml_config_service.rb | 52 +++++ app/views/devise/sessions/new.html.erb | 32 ++- app/views/shared/_settings_nav.html.erb | 4 +- app/views/sso_settings/_saml_form.html.erb | 160 +++++++++++++ .../{index.html.erb => show.html.erb} | 2 +- config/initializers/devise.rb | 79 +++++++ config/routes.rb | 14 +- .../20250719104801_add_omniauth_to_users.rb | 10 + db/schema.rb | 11 +- docker-compose.dev.yml | 89 +++++++ 20 files changed, 1122 insertions(+), 24 deletions(-) create mode 100644 Dockerfile.dev create mode 100644 OMNIAUTH_IMPLEMENTATION.md create mode 100644 app/controllers/users/omniauth_callbacks_controller.rb create mode 100644 app/services/saml_config_service.rb create mode 100644 app/views/sso_settings/_saml_form.html.erb rename app/views/sso_settings/{index.html.erb => show.html.erb} (88%) create mode 100644 db/migrate/20250719104801_add_omniauth_to_users.rb create mode 100644 docker-compose.dev.yml diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..f891b318 --- /dev/null +++ b/Dockerfile.dev @@ -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"] diff --git a/Gemfile b/Gemfile index d0e64233..39e26576 100644 --- a/Gemfile +++ b/Gemfile @@ -13,6 +13,11 @@ gem 'cancancan' gem 'csv' gem 'devise' 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 'email_typo' gem 'faraday' diff --git a/Gemfile.lock b/Gemfile.lock index b55adaa1..aa7f294c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -270,6 +270,7 @@ GEM os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) hashdiff (1.1.2) + hashie (5.0.0) hexapdf (1.0.3) cmdparse (~> 3.0, >= 3.0.3) geom2d (~> 0.4, >= 0.4.1) @@ -323,6 +324,8 @@ GEM minitest (5.25.4) msgpack (1.7.5) multi_json (1.15.0) + multi_xml (0.7.2) + bigdecimal (~> 3.1) multipart-post (2.4.1) mutex_m (0.3.0) mysql2 (0.5.6) @@ -351,9 +354,39 @@ GEM racc (~> 1.4) nokogiri (1.18.8-x86_64-linux-musl) 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) bigdecimal (>= 3.0) 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) orm_adapter (0.5.0) os (1.1.4) @@ -388,6 +421,10 @@ GEM nio4r (~> 2.0) racc (1.8.1) 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 rack-session (2.0.0) @@ -498,6 +535,9 @@ GEM rubocop-rspec (3.3.0) rubocop (~> 1.61) ruby-progressbar (1.13.0) + ruby-saml (1.18.0) + nokogiri (>= 1.13.10) + rexml ruby-vips (2.2.2) ffi (~> 1.12) logger @@ -531,6 +571,9 @@ GEM simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) 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) mini_portile2 (~> 2.8.0) sqlite3 (2.5.0-aarch64-linux-gnu) @@ -562,6 +605,7 @@ GEM uniform_notifier (1.16.0) uri (1.0.3) useragent (0.16.11) + version_gem (1.1.8) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.1) @@ -622,6 +666,11 @@ DEPENDENCIES lograge mysql2 oj + omniauth + omniauth-google-oauth2 + omniauth-microsoft_graph + omniauth-rails_csrf_protection + omniauth-saml pagy pg premailer-rails diff --git a/OMNIAUTH_IMPLEMENTATION.md b/OMNIAUTH_IMPLEMENTATION.md new file mode 100644 index 00000000..d5a12e82 --- /dev/null +++ b/OMNIAUTH_IMPLEMENTATION.md @@ -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 diff --git a/app/controllers/sso_settings_controller.rb b/app/controllers/sso_settings_controller.rb index 3e9b6bba..be24633c 100644 --- a/app/controllers/sso_settings_controller.rb +++ b/app/controllers/sso_settings_controller.rb @@ -2,9 +2,53 @@ class SsoSettingsController < ApplicationController 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 @@ -12,4 +56,77 @@ class SsoSettingsController < ApplicationController @encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account, key: 'saml_configs') 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 diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb new file mode 100644 index 00000000..5b93305b --- /dev/null +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -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 + + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + + + + + + + + + + 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 diff --git a/app/models/document_generation_event.rb b/app/models/document_generation_event.rb index 95daafa6..47a12b11 100644 --- a/app/models/document_generation_event.rb +++ b/app/models/document_generation_event.rb @@ -13,7 +13,7 @@ # Indexes # # 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 # diff --git a/app/models/email_event.rb b/app/models/email_event.rb index ed796226..3a182565 100644 --- a/app/models/email_event.rb +++ b/app/models/email_event.rb @@ -20,7 +20,7 @@ # # index_email_events_on_account_id_and_event_datetime (account_id,event_datetime) # 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_message_id (message_id) # diff --git a/app/models/submission.rb b/app/models/submission.rb index 4e61c691..830db2e3 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -10,7 +10,7 @@ # name :text # preferences :text not null # slug :string not null -# source :text not null +# source :string not null # submitters_order :string not null # template_fields :text # template_schema :text diff --git a/app/models/user.rb b/app/models/user.rb index f840ca7d..2c589bca 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,13 +19,15 @@ # locked_at :datetime # otp_required_for_login :boolean default(FALSE), not null # otp_secret :string +# provider :string # remember_created_at :datetime # reset_password_sent_at :datetime # reset_password_token :string # role :string not null # sign_in_count :integer default(0), not null +# uid :string # unlock_token :string -# uuid :text not null +# uuid :string not null # created_at :datetime not null # updated_at :datetime not null # account_id :bigint not null @@ -34,6 +36,7 @@ # # index_users_on_account_id (account_id) # 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_unlock_token (unlock_token) 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 :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 :uuid, :string, default: -> { SecureRandom.uuid } @@ -116,4 +119,56 @@ class User < ApplicationRecord email 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 diff --git a/app/services/saml_config_service.rb b/app/services/saml_config_service.rb new file mode 100644 index 00000000..bbac43bf --- /dev/null +++ b/app/services/saml_config_service.rb @@ -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 diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index af589843..f5cae5d8 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -20,20 +20,44 @@ <% end %> <% if devise_mapping.omniauthable? %>
- <% if User.omniauth_providers.include?(:google_oauth2) %> - <%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %> + <% 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: '/auth/google_oauth2', data: { turbo: false }, method: :post do |f| %> <%= 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' %> <% end %> <% end %> - <% if User.omniauth_providers.include?(:microsoft_office365) %> - <%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %> + <% 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: '/auth/microsoft_graph', data: { turbo: false }, method: :post do |f| %> <%= 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' %> <% 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| %> + + <%= 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 %>
<% end %> <%= render 'extra_links' %> diff --git a/app/views/shared/_settings_nav.html.erb b/app/views/shared/_settings_nav.html.erb index 8355f543..a8cde08a 100644 --- a/app/views/shared/_settings_nav.html.erb +++ b/app/views/shared/_settings_nav.html.erb @@ -83,9 +83,9 @@ <% 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 %>
  • - <%= 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' %>
  • <% end %> <%= render 'shared/settings_nav_extra2' %> diff --git a/app/views/sso_settings/_saml_form.html.erb b/app/views/sso_settings/_saml_form.html.erb new file mode 100644 index 00000000..6704c07b --- /dev/null +++ b/app/views/sso_settings/_saml_form.html.erb @@ -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| %> +
    +
    + <%= svg_icon('info_circle', class: 'w-6 h-6') %> +
    +

    SAML SSO Configuration

    +

    + Configure SAML 2.0 Single Sign-On for your organization. Users will be able to sign in using your identity provider. +

    +
    +
    + + <% saml_config = @encrypted_config.value.present? ? JSON.parse(@encrypted_config.value) : {} %> + + +
    +
    +

    Quick Setup: Upload IdP Metadata

    +

    + Upload your Identity Provider's metadata XML file to automatically populate the configuration below. +

    + +
    + <%= 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' %> +
    + Select your IdP's metadata.xml file to auto-configure SAML settings +
    +
    + +
    + <%= submit_tag 'Parse Metadata', class: 'btn btn-primary btn-sm' %> +
    +
    +
    + +
    OR configure manually
    + +
    + <%= 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 %> +
    + The URL where users will be redirected to authenticate +
    +
    + +
    + <%= 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 %> +
    + SHA1 fingerprint of your IdP's certificate +
    +
    + +
    + <%= 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 %> +
    + Unique identifier for this DocuSeal instance +
    +
    + +
    + <%= 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' %> +
    + Format for the user identifier sent by your IdP +
    +
    + +
    Attribute Mapping
    + +
    + <%= 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' %> +
    + SAML attribute name that contains the user's email +
    +
    + +
    + <%= 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' %> +
    + SAML attribute name that contains the user's first name +
    +
    + +
    + <%= 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' %> +
    + SAML attribute name that contains the user's last name +
    +
    + +
    Service Provider Information
    + +
    + <%= svg_icon('info_circle', class: 'w-6 h-6') %> +
    +

    Configuration URLs for your Identity Provider

    +
    +

    Assertion Consumer Service URL:

    + <%= "#{request.base_url}/auth/saml/callback" %> + +

    SP Metadata URL:

    + + +

    SP Entity ID:

    + <%= saml_config['sp_entity_id'] || 'docuseal' %> +
    +
    +
    + +
    + <%= 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 %> +
    +
    +<% end %> diff --git a/app/views/sso_settings/index.html.erb b/app/views/sso_settings/show.html.erb similarity index 88% rename from app/views/sso_settings/index.html.erb rename to app/views/sso_settings/show.html.erb index a0ebddba..5388f93b 100644 --- a/app/views/sso_settings/index.html.erb +++ b/app/views/sso_settings/show.html.erb @@ -2,7 +2,7 @@ <%= render 'shared/settings_nav' %>

    SAML SSO

    - <%= render 'placeholder' %> + <%= render 'saml_form' %>
    diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 154deb25..036e7831 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -330,5 +330,84 @@ 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 + # ==> 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) end diff --git a/config/routes.rb b/config/routes.rb index 20015262..9d958fa3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,13 +16,15 @@ Rails.application.routes.draw do devise_for :users, path: '/', only: %i[sessions passwords omniauth_callbacks], - controllers: begin - options = { sessions: 'sessions', passwords: 'passwords' } - options[:omniauth_callbacks] = 'omniauth_callbacks' if User.devise_modules.include?(:omniauthable) - options - end + controllers: { + sessions: 'sessions', + passwords: 'passwords', + omniauth_callbacks: 'users/omniauth_callbacks' + } 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 get '' => :edit end @@ -168,7 +170,7 @@ Rails.application.routes.draw do resources :sms, only: %i[index], controller: 'sms_settings' end 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' resource :esign, only: %i[show create new update destroy], controller: 'esign_settings' resources :users, only: %i[index] diff --git a/db/migrate/20250719104801_add_omniauth_to_users.rb b/db/migrate/20250719104801_add_omniauth_to_users.rb new file mode 100644 index 00000000..a3c39610 --- /dev/null +++ b/db/migrate/20250719104801_add_omniauth_to_users.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 3fcb693a..cc8bcf51 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,10 @@ # # 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 enable_extension "btree_gin" - enable_extension "plpgsql" + enable_extension "pg_catalog.plpgsql" create_table "access_tokens", force: :cascade do |t| 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.datetime "created_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" end @@ -177,7 +177,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_130628) do t.datetime "created_at", null: false 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_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 ["message_id"], name: "index_email_events_on_message_id" end @@ -430,8 +430,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_130628) do t.string "otp_secret" t.integer "consumed_timestep" 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 ["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 ["unlock_token"], name: "index_users_on_unlock_token", unique: true t.index ["uuid"], name: "index_users_on_uuid", unique: true diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..9c96062c --- /dev/null +++ b/docker-compose.dev.yml @@ -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: