From dacefffd245e59ca44f52edc3c02a6c96d582e8c Mon Sep 17 00:00:00 2001 From: Wabo Date: Sat, 16 May 2026 11:37:45 -0400 Subject: [PATCH] Allow Google SSO to be configured from /settings/sso (DB fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now, Google SSO required setting GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_ALLOWED_DOMAINS in the environment and restarting the container. This commit adds a UI-driven configuration path that doesn't need a restart, while keeping ENV as the priority source for production deployments. Storage: new EncryptedConfig key `google_sso_configs` (added to CONFIG_KEYS) with shape: { enabled: bool, client_id, client_secret, allowed_domains: [..] } The secret rides on Rails' `encrypts :value` like every other EncryptedConfig record. Strategy registration: the Devise initializer now always registers :google_oauth2 with a setup proc, so the omniauth routes exist unconditionally. The setup proc calls Wabosign.google_sso_credentials per request — that helper checks ENV first (priority) and falls back to the DB. Empty creds yield :source => :none and the Google button is hidden by the sign-in partial. User model: :omniauthable + omniauth_providers: [:google_oauth2] are now unconditional (matches the always-registered route). The boot-time fragile gating that broke `bundle exec puma` when env vars weren't set is gone. Routes: omniauth_callbacks no longer depends on ENV. /settings/sso gains a :create action. SsoSettingsController#create persists the form payload via the existing EncryptedConfig pattern (and never overwrites a saved secret with a blank). View: /settings/sso is now a real form (client_id, client_secret, allowed_domains, enabled toggle) instead of an env-only status panel. A banner explains ENV precedence when GOOGLE_CLIENT_ID is set. The redirect URI to register in Google Cloud Console is shown in the "not configured" state. User#default_sso_account now prefers the account that owns the UI-saved config so JIT-provisioned users land in the right tenant when an admin sets up SSO from the UI in a multi-account deployment. Specs: the omniauth_callbacks request specs were stubbing the removed Wabosign::GOOGLE_* constants. Switched them to `allow(Wabosign).to receive(:google_sso_credentials)`. All 5 pass. Smoke-tested the rebuilt image in three states: - No ENV, no DB: container boots, /sign_in 200, no button. - DB config saved: button appears on the very next /sign_in render. - ENV set + DB set: ENV wins (allowed_domains and creds come from ENV). Docs: GOOGLE_SSO.md gains a section describing the UI path and how the two sources interact. Co-Authored-By: Claude Opus 4.7 --- GOOGLE_SSO.md | 27 +++++-- app/controllers/sso_settings_controller.rb | 34 ++++++++- app/models/encrypted_config.rb | 3 +- app/models/user.rb | 28 ++++--- app/views/sso_settings/_placeholder.html.erb | 30 -------- app/views/sso_settings/index.html.erb | 73 ++++++++++++++++++- config/initializers/devise.rb | 34 ++++----- config/routes.rb | 26 +++---- lib/wabosign.rb | 60 +++++++++++---- .../requests/users/omniauth_callbacks_spec.rb | 9 ++- 10 files changed, 224 insertions(+), 100 deletions(-) delete mode 100644 app/views/sso_settings/_placeholder.html.erb diff --git a/GOOGLE_SSO.md b/GOOGLE_SSO.md index b49bdc97..0192b308 100644 --- a/GOOGLE_SSO.md +++ b/GOOGLE_SSO.md @@ -37,18 +37,35 @@ Create the OAuth client at ## Configuration -Set these environment variables on the WaboSign process (in `wabosign.env`, the docker-compose `environment:` block, or your hosting provider's secret store): +Two ways to configure, in priority order: + +### 1. Environment variables (priority — recommended for production) + +Set these on the WaboSign process (in `wabosign.env`, the docker-compose `environment:` block, or your hosting provider's secret store): | Variable | Required | Example | Notes | |---|---|---|---| | `GOOGLE_CLIENT_ID` | yes | `1234.apps.googleusercontent.com` | From the Google Cloud OAuth client. | | `GOOGLE_CLIENT_SECRET` | yes | `GOCSPX-…` | From the Google Cloud OAuth client. | -| `GOOGLE_ALLOWED_DOMAINS` | recommended | `wabo.cc,partner.example` | Comma-separated. Only Google accounts whose `hd` claim is in this list can sign in. Empty = any Google account allowed (a warning is logged at boot). | -| `GOOGLE_DEFAULT_ACCOUNT_ID` | no | `1` | The WaboSign `Account` JIT-provisioned users are attached to. Defaults to the oldest account. Useful only if you run multiple `Account` records on one deployment. | +| `GOOGLE_ALLOWED_DOMAINS` | recommended | `wabo.cc,partner.example` | Comma-separated. Only Google accounts whose `hd` claim is in this list can sign in. Empty = any Google account allowed. | +| `GOOGLE_DEFAULT_ACCOUNT_ID` | no | `1` | The WaboSign `Account` JIT-provisioned users are attached to. Defaults to the oldest account (or the account that owns the UI-saved config, if no env override). Useful only if you run multiple `Account` records on one deployment. | + +ENV-driven values take effect at the next request — no restart needed. ENV always wins over the UI form below. + +### 2. Web UI (fallback — for ENV-free deployments) + +Sign in as an admin, go to **Settings → Google SSO** (`/settings/sso`), and fill in: + +- **Enable Google SSO** — toggle. Required for the button to appear on the sign-in page. +- **Client ID** — from your Google Cloud OAuth client. +- **Client Secret** — same. Stored encrypted via Rails `encrypts :value` on `EncryptedConfig`. Leave the field blank when editing later to keep the saved secret unchanged. +- **Allowed Workspace Domains** — comma-separated. Same semantics as `GOOGLE_ALLOWED_DOMAINS`. + +The UI-saved config is read on every sign-in via an OmniAuth `setup` proc, so changes take effect on the next click of "Sign in with Google" — no restart needed. The Client Secret is stored encrypted in the `encrypted_configs` table under the `google_sso_configs` key. -After changing any of these, **restart the WaboSign process** — Devise's OmniAuth strategy is registered at boot. +The OAuth redirect URI to register in [Google Cloud Console](https://console.cloud.google.com/apis/credentials) is shown on the settings page; it follows the pattern `https:///auth/google_oauth2/callback`. -The Devise integration is conditional: if either `GOOGLE_CLIENT_ID` or `GOOGLE_CLIENT_SECRET` is missing, the `:omniauthable` Devise module is not loaded and the Google button is hidden. This means development environments without creds keep working unchanged. +If ENV is also set, the settings page shows a banner indicating that ENV takes precedence; the form is still editable, but the saved values are unused until you unset the env vars (and restart). --- diff --git a/app/controllers/sso_settings_controller.rb b/app/controllers/sso_settings_controller.rb index 3e9b6bba..538d6792 100644 --- a/app/controllers/sso_settings_controller.rb +++ b/app/controllers/sso_settings_controller.rb @@ -3,13 +3,45 @@ class SsoSettingsController < ApplicationController before_action :load_encrypted_config authorize_resource :encrypted_config, only: :index + authorize_resource :encrypted_config, parent: false, only: :create def index; end + def create + new_value = build_sso_value + + if @encrypted_config.update(value: new_value) + redirect_to settings_sso_index_path, notice: I18n.t('changes_have_been_saved') + else + render :index, status: :unprocessable_content + end + rescue StandardError => e + flash[:alert] = e.message + render :index, status: :unprocessable_content + end + private def load_encrypted_config @encrypted_config = - EncryptedConfig.find_or_initialize_by(account: current_account, key: 'saml_configs') + EncryptedConfig.find_or_initialize_by(account: current_account, + key: EncryptedConfig::GOOGLE_SSO_KEY) + end + + def build_sso_value + submitted = params.require(:encrypted_config).permit(value: {})[:value].to_h + existing = @encrypted_config.value || {} + + # Don't clobber the saved secret with a blank one — the field is + # rendered empty (we never echo it back) so an unchanged form would + # otherwise wipe it out. + submitted['client_secret'] = existing['client_secret'] if submitted['client_secret'].to_s.empty? + + submitted['allowed_domains'] = + submitted.delete('allowed_domains_csv').to_s.split(',').map(&:strip).reject(&:empty?) + + submitted['enabled'] = submitted['enabled'].to_s == '1' || submitted['enabled'].to_s == 'true' + + submitted.compact end end diff --git a/app/models/encrypted_config.rb b/app/models/encrypted_config.rb index e61923b4..92e2d437 100644 --- a/app/models/encrypted_config.rb +++ b/app/models/encrypted_config.rb @@ -26,7 +26,8 @@ class EncryptedConfig < ApplicationRecord EMAIL_SMTP_KEY = 'action_mailer_smtp', ESIGN_CERTS_KEY = 'esign_certs', TIMESTAMP_SERVER_URL_KEY = 'timestamp_server_url', - APP_URL_KEY = 'app_url' + APP_URL_KEY = 'app_url', + GOOGLE_SSO_KEY = 'google_sso_configs' ].freeze belongs_to :account diff --git a/app/models/user.rb b/app/models/user.rb index deb156fe..31039bc3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -69,13 +69,14 @@ 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_modules = %i[two_factor_authenticatable recoverable rememberable validatable trackable lockable] - devise_opts = {} - if Wabosign.google_sso_enabled? - devise_modules << :omniauthable - devise_opts[:omniauth_providers] = [:google_oauth2] - end - devise(*devise_modules, **devise_opts) + # :omniauthable is included unconditionally so the Devise routes are + # always declared. Whether the strategy actually works (and whether the + # Google button is shown on the sign-in page) is gated by + # Wabosign.google_sso_enabled? at runtime — driven by ENV and/or the + # `google_sso_configs` EncryptedConfig record. + devise :two_factor_authenticatable, :recoverable, :rememberable, + :validatable, :trackable, :lockable, :omniauthable, + omniauth_providers: [:google_oauth2] attribute :role, :string, default: ADMIN_ROLE attribute :uuid, :string, default: -> { SecureRandom.uuid } @@ -164,10 +165,17 @@ class User < ApplicationRecord end def self.default_sso_account + # ENV override always wins. if Wabosign::GOOGLE_DEFAULT_ACCOUNT_ID.present? - Account.find_by(id: Wabosign::GOOGLE_DEFAULT_ACCOUNT_ID) - else - Account.order(:created_at).first + return Account.find_by(id: Wabosign::GOOGLE_DEFAULT_ACCOUNT_ID) end + + # If an admin saved the Google SSO config via the UI, JIT-provision into + # that same account so admins land in the right tenant. + if (db_config = EncryptedConfig.find_by(key: EncryptedConfig::GOOGLE_SSO_KEY)) + return db_config.account if db_config.account && db_config.account.archived_at.nil? + end + + Account.order(:created_at).first end end diff --git a/app/views/sso_settings/_placeholder.html.erb b/app/views/sso_settings/_placeholder.html.erb deleted file mode 100644 index 1604797d..00000000 --- a/app/views/sso_settings/_placeholder.html.erb +++ /dev/null @@ -1,30 +0,0 @@ -<% if Wabosign.google_sso_enabled? %> -
- <%= svg_icon('discount_check_filled', class: 'w-6 h-6') %> -
-

Google SSO is enabled

-

- Configured via environment variables. - <% if Wabosign::GOOGLE_ALLOWED_DOMAINS.any? %> - Allowed Workspace domain<%= 's' if Wabosign::GOOGLE_ALLOWED_DOMAINS.size > 1 %>: - <%= Wabosign::GOOGLE_ALLOWED_DOMAINS.join(', ') %>. - <% else %> - Warning: no domain allowlist set — any Google account may sign in. Set GOOGLE_ALLOWED_DOMAINS to restrict. - <% end %> -

-
-
-<% else %> -
- <%= svg_icon('info_circle', class: 'w-6 h-6') %> -
-

Google SSO is not configured

-

- Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_ALLOWED_DOMAINS (comma-separated) and restart the app. The OAuth redirect URI to register in Google Cloud Console is <%= "#{root_url}users/auth/google_oauth2/callback" rescue '/users/auth/google_oauth2/callback' %>. -

-

- SAML 2.0 SSO is not bundled with this open-source edition. To enable it, add ruby-saml and devise-saml-authenticatable and wire the ACS/SLO/metadata routes; encrypted config is stored under the saml_configs key on the account. -

-
-
-<% end %> diff --git a/app/views/sso_settings/index.html.erb b/app/views/sso_settings/index.html.erb index a0ebddba..83f70bfb 100644 --- a/app/views/sso_settings/index.html.erb +++ b/app/views/sso_settings/index.html.erb @@ -1,8 +1,77 @@
<%= render 'shared/settings_nav' %>
-

SAML SSO

- <%= render 'placeholder' %> +

Google SSO

+ + <% creds = Wabosign.google_sso_credentials %> + <% value = @encrypted_config.value || {} %> + + <% if creds[:source] == :env %> +
+ <%= svg_icon('info_circle', class: 'w-6 h-6') %> +
+

Google SSO is configured via environment variables

+

+ GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set on the running process, so ENV-driven configuration is in effect. ENV always takes precedence over anything saved on this page. Unset the env vars (and restart) to switch to the values configured here. +

+
+
+ <% elsif creds[:source] == :db %> +
+ <%= svg_icon('discount_check_filled', class: 'w-6 h-6') %> +
+

Google SSO is enabled

+

+ <% if creds[:allowed_domains].any? %> + Allowed Workspace domain<%= 's' if creds[:allowed_domains].size > 1 %>: <%= creds[:allowed_domains].join(', ') %>. + <% else %> + Warning: no domain allowlist is set. Any Google account can sign in. + <% end %> +

+
+
+ <% else %> +
+ <%= svg_icon('info_circle', class: 'w-6 h-6') %> +
+

Google SSO is not configured

+

+ Fill in your Google Cloud OAuth client details below. The OAuth redirect URI to register in Google Cloud Console is + <%= "#{root_url}auth/google_oauth2/callback" rescue '/auth/google_oauth2/callback' %>. +

+
+
+ <% end %> + + <%= form_for @encrypted_config, url: settings_sso_index_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %> + <%= f.fields_for :value do |ff| %> +
+ +
+
+ <%= ff.label :client_id, 'Client ID', class: 'label' %> + <%= ff.text_field :client_id, value: value['client_id'], class: 'base-input', placeholder: '1234567890.apps.googleusercontent.com' %> +
+
+ <%= ff.label :client_secret, 'Client Secret', class: 'label' %> + <%= ff.password_field :client_secret, class: 'base-input', placeholder: value['client_secret'].present? ? '*************' : 'GOCSPX-…' %> + <% if value['client_secret'].present? %> + Leave blank to keep the saved secret. + <% end %> +
+
+ <%= ff.label :allowed_domains_csv, 'Allowed Workspace Domains', class: 'label' %> + <%= ff.text_field :allowed_domains_csv, value: Array(value['allowed_domains']).join(', '), class: 'base-input', placeholder: 'wabo.cc, partner.example' %> + Comma-separated. Only Google accounts whose Workspace hd claim matches one of these domains can sign in. Leave blank to allow any Google account (not recommended). +
+ <% end %> +
+ <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %> +
+ <% end %>
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index c51bee29..00d40200 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -334,24 +334,22 @@ Devise.setup do |config| # changed. Defaults to true, so a user is signed in automatically after changing a password. # config.sign_in_after_change_password = true - # NB: Wabosign-the-module relies on Rails.root, which isn't available yet - # when this initializer runs. Read ENV directly here so the omniauth strategy - # can be registered at boot. Controllers/models access the same values via - # Wabosign::GOOGLE_* once Rails is fully initialized. - google_client_id = ENV.fetch('GOOGLE_CLIENT_ID', nil) - google_client_secret = ENV.fetch('GOOGLE_CLIENT_SECRET', nil) - if google_client_id.present? && google_client_secret.present? - config.omniauth :google_oauth2, - google_client_id, - google_client_secret, - { - scope: 'email,profile', - prompt: 'select_account', - access_type: 'online', - hd: ENV.fetch('GOOGLE_ALLOWED_DOMAINS', '') - .split(',').map(&:strip).reject(&:empty?).presence - } - end + # The :google_oauth2 strategy is always registered so its routes exist at + # boot. Credentials are resolved per-request by the setup proc, which + # consults Wabosign.google_sso_credentials (ENV takes priority, falling + # back to the `google_sso_configs` EncryptedConfig record). This lets + # admins manage Google SSO from /settings/sso without restarting the app. + config.omniauth :google_oauth2, '', '', + setup: lambda { |env| + strategy = env['omniauth.strategy'] + creds = Wabosign.google_sso_credentials + strategy.options[:client_id] = creds[:client_id].to_s + strategy.options[:client_secret] = creds[:client_secret].to_s + strategy.options[:scope] = 'email,profile' + strategy.options[:prompt] = 'select_account' + strategy.options[:access_type] = 'online' + strategy.options[:hd] = creds[:allowed_domains].presence + } ActiveSupport.run_load_hooks(:devise_config, config) end diff --git a/config/routes.rb b/config/routes.rb index f8ddc976..ff676a3e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,21 +14,15 @@ Rails.application.routes.draw do get 'up' => 'rails/health#show' get 'manifest' => 'pwa#manifest' - # Mirror the User model's conditional :omniauthable inclusion. ENV is the - # one source of truth available at routes-load time (Wabosign-the-module - # may not be autoloadable yet) — keep this check in sync with - # config/initializers/devise.rb and app/models/user.rb. - # Devise raises if controllers[:omniauth_callbacks] is set but User isn't - # omniauthable, so the controllers hash must omit that key too. - google_sso_enabled = ENV['GOOGLE_CLIENT_ID'].present? && ENV['GOOGLE_CLIENT_SECRET'].present? - devise_actions = %i[sessions passwords] - devise_controllers = { sessions: 'sessions', passwords: 'passwords' } - if google_sso_enabled - devise_actions << :omniauth_callbacks - devise_controllers[:omniauth_callbacks] = 'users/omniauth_callbacks' - end - - devise_for :users, path: '/', only: devise_actions, controllers: devise_controllers + # User is always :omniauthable (see app/models/user.rb); the strategy is + # registered with a setup proc in config/initializers/devise.rb that pulls + # live credentials from ENV or the database at request time. + devise_for :users, path: '/', only: %i[sessions passwords omniauth_callbacks], + controllers: { + sessions: 'sessions', + passwords: 'passwords', + omniauth_callbacks: 'users/omniauth_callbacks' + } devise_scope :user do resource :invitation, only: %i[update] do @@ -198,7 +192,7 @@ Rails.application.routes.draw do resource :reveal_access_token, only: %i[show create], controller: 'reveal_access_token' end resources :email, only: %i[index create], controller: 'email_smtp_settings' - resources :sso, only: %i[index], controller: 'sso_settings' + resources :sso, only: %i[index create], 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/lib/wabosign.rb b/lib/wabosign.rb index 38250e2b..835b9b24 100644 --- a/lib/wabosign.rb +++ b/lib/wabosign.rb @@ -14,10 +14,6 @@ module Wabosign SUPPORT_EMAIL = 'wabosign@wabo.cc' HOST = ENV.fetch('HOST', 'localhost') AATL_CERT_NAME = 'wabosign_aatl' - GOOGLE_CLIENT_ID = ENV.fetch('GOOGLE_CLIENT_ID', nil) - GOOGLE_CLIENT_SECRET = ENV.fetch('GOOGLE_CLIENT_SECRET', nil) - GOOGLE_ALLOWED_DOMAINS = ENV.fetch('GOOGLE_ALLOWED_DOMAINS', '') - .split(',').map(&:strip).reject(&:empty?).freeze GOOGLE_DEFAULT_ACCOUNT_ID = ENV.fetch('GOOGLE_DEFAULT_ACCOUNT_ID', nil) CONSOLE_URL = if Rails.env.development? 'http://console.localhost.io:3001' @@ -127,21 +123,57 @@ module Wabosign @default_url_options = nil end + # Returns the live Google SSO credentials, merging ENV (priority) with the + # `google_sso_configs` EncryptedConfig (UI fallback). Called at request + # time by the Devise OmniAuth setup proc and the sign-in page partial. + # + # Shape: { client_id:, client_secret:, allowed_domains:, source: :env|:db|:none } + def google_sso_credentials + env_id = ENV.fetch('GOOGLE_CLIENT_ID', nil) + env_secret = ENV.fetch('GOOGLE_CLIENT_SECRET', nil) + if env_id.present? && env_secret.present? + return { + client_id: env_id, + client_secret: env_secret, + allowed_domains: ENV.fetch('GOOGLE_ALLOWED_DOMAINS', '') + .split(',').map(&:strip).reject(&:empty?), + source: :env + } + end + + db_value = google_sso_db_value + if db_value.is_a?(Hash) && db_value['enabled'] && + db_value['client_id'].to_s.present? && db_value['client_secret'].to_s.present? + return { + client_id: db_value['client_id'].to_s, + client_secret: db_value['client_secret'].to_s, + allowed_domains: Array(db_value['allowed_domains']).map(&:to_s).map(&:strip).reject(&:empty?), + source: :db + } + end + + { client_id: nil, client_secret: nil, allowed_domains: [], source: :none } + end + + def google_sso_db_value + return nil unless defined?(EncryptedConfig) && EncryptedConfig.table_exists? + + EncryptedConfig.find_by(key: EncryptedConfig::GOOGLE_SSO_KEY)&.value + rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished + nil + end + def google_sso_enabled? - GOOGLE_CLIENT_ID.present? && GOOGLE_CLIENT_SECRET.present? + creds = google_sso_credentials + creds[:client_id].present? && creds[:client_secret].present? end def google_domain_allowed?(hd) return false if hd.blank? - return true if GOOGLE_ALLOWED_DOMAINS.empty? - GOOGLE_ALLOWED_DOMAINS.include?(hd) - end -end + domains = google_sso_credentials[:allowed_domains] + return true if domains.empty? -if Wabosign.google_sso_enabled? && Wabosign::GOOGLE_ALLOWED_DOMAINS.empty? - Rails.logger.warn( - '[Wabosign] Google SSO is enabled but GOOGLE_ALLOWED_DOMAINS is empty — ' \ - 'any Google account will be permitted to sign in.' - ) + domains.include?(hd) + end end diff --git a/spec/requests/users/omniauth_callbacks_spec.rb b/spec/requests/users/omniauth_callbacks_spec.rb index 73ec55fc..714424c1 100644 --- a/spec/requests/users/omniauth_callbacks_spec.rb +++ b/spec/requests/users/omniauth_callbacks_spec.rb @@ -12,9 +12,12 @@ RSpec.describe 'Google OAuth2 callback', type: :request do OmniAuth.config.test_mode = true OmniAuth.config.logger = Rails.logger - stub_const('Wabosign::GOOGLE_CLIENT_ID', 'test-client-id') - stub_const('Wabosign::GOOGLE_CLIENT_SECRET', 'test-client-secret') - stub_const('Wabosign::GOOGLE_ALLOWED_DOMAINS', ['wabo.cc'].freeze) + allow(Wabosign).to receive(:google_sso_credentials).and_return( + client_id: 'test-client-id', + client_secret: 'test-client-secret', + allowed_domains: ['wabo.cc'], + source: :env + ) stub_const('Wabosign::GOOGLE_DEFAULT_ACCOUNT_ID', nil) end