From ad12ef7fb53c62fb890bb37cd91371be9c1817db Mon Sep 17 00:00:00 2001 From: Wabo Date: Fri, 15 May 2026 23:00:17 -0400 Subject: [PATCH] Add Google Workspace SSO via omniauth-google-oauth2 Adds "Sign in with Google" as an additive auth path next to email and password. When GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set, the Google button appears on the sign-in page and the SSO settings page shows an env-driven status panel. Access is restricted to Workspace domains listed in GOOGLE_ALLOWED_DOMAINS (CSV); the hd claim is re-verified server-side on every callback so a misconfigured Google consent screen cannot bypass it. New users from an allowed domain are JIT-provisioned in the default account (oldest, or pinned via GOOGLE_DEFAULT_ACCOUNT_ID). Existing users with a matching email get linked to their Google identity on first sign-in; identity collisions (same email, different Google uid) are rejected. Google's MFA is trusted: users signed in via Google do not see the WaboSign OTP prompt or the FORCE_MFA setup redirect. Password sign-in keeps working unchanged, including its existing OTP gate. Implementation: - Devise gains :omniauthable when SSO is enabled; users get provider/uid columns with a partial unique index that allows NULL for password-only rows. - Users::OmniauthCallbacksController handles /users/auth/google_oauth2/ callback, sets session[:bypass_otp_for_sso], and redirects on failure. - SessionsController#destroy clears the bypass flag on sign-out. - DashboardController#maybe_redirect_mfa_setup honours the flag and User#signed_in_via_sso?. - The previously empty _omniauthable.html.erb stub now renders the Google button. Request specs cover happy path, link-existing-user, domain rejection, identity collision, and 2FA bypass. GOOGLE_SSO.md is the operator-facing setup, behaviour, verification, and troubleshooting guide. README links to it. Co-Authored-By: Claude Opus 4.7 --- GOOGLE_SSO.md | 161 ++++++++++++++++++ Gemfile | 3 + README.md | 6 +- app/controllers/dashboard_controller.rb | 1 + app/controllers/sessions_controller.rb | 5 + .../users/omniauth_callbacks_controller.rb | 27 +++ app/models/user.rb | 47 ++++- .../devise/sessions/_omniauthable.html.erb | 12 ++ app/views/sso_settings/_placeholder.html.erb | 39 +++-- config/initializers/devise.rb | 12 ++ config/routes.rb | 8 +- .../20260515200000_add_omniauth_to_users.rb | 9 + lib/wabosign.rb | 23 +++ public/google_g.svg | 7 + .../requests/users/omniauth_callbacks_spec.rb | 113 ++++++++++++ 15 files changed, 459 insertions(+), 14 deletions(-) create mode 100644 GOOGLE_SSO.md create mode 100644 app/controllers/users/omniauth_callbacks_controller.rb create mode 100644 db/migrate/20260515200000_add_omniauth_to_users.rb create mode 100644 public/google_g.svg create mode 100644 spec/requests/users/omniauth_callbacks_spec.rb diff --git a/GOOGLE_SSO.md b/GOOGLE_SSO.md new file mode 100644 index 00000000..b49bdc97 --- /dev/null +++ b/GOOGLE_SSO.md @@ -0,0 +1,161 @@ +# Google SSO + +WaboSign supports "Sign in with Google" as an additive authentication path. Once you set the environment variables below, a Google button appears on the sign-in page alongside the existing email-and-password form. Password sign-in keeps working — SSO does not replace it. + +This document covers operator setup, runtime behaviour, verification, and troubleshooting. + +--- + +## What you get + +- "Sign in with Google" button on `/users/sign_in` whenever the env vars are set. +- Domain-restricted access: only Google accounts whose Workspace `hd` (hosted-domain) claim matches your allowlist can sign in. +- Just-in-time (JIT) user provisioning: a first-time Google sign-in from an allowed domain creates a WaboSign user in the default account. +- 2FA bypass: a user who signed in via Google is not prompted for the WaboSign OTP — Google's MFA is trusted. +- Password sign-in continues to work for any user that has a password (additive, not replaced). + +What you *don't* get out of the box: + +- No admin UI for credentials — configuration is via env vars only. +- No SAML/Okta/Entra/Keycloak — Google-only. (See the SSO settings page for hints on adding SAML if you need it later.) +- No role mapping from Google Workspace groups — every JIT-created user is `role: 'admin'` (the only role WaboSign ships with). +- No automatic deprovisioning when a user is removed from your Workspace — manage WaboSign accounts via the existing Users settings page. + +--- + +## Prerequisites + +1. A Google Cloud project with the "Google Identity" / OAuth consent screen configured. +2. An "OAuth client ID" of type **Web application** with: + - **Authorized redirect URI**: `https:///users/auth/google_oauth2/callback` + - Authorized JavaScript origins are not required. +3. A Google Workspace domain whose users should be allowed to sign in (for personal Gmail accounts, see "Open access" below). + +Create the OAuth client at → **Create credentials → OAuth client ID**. Copy the client ID and client secret to your environment. + +--- + +## Configuration + +Set these environment variables 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. | + +After changing any of these, **restart the WaboSign process** — Devise's OmniAuth strategy is registered at boot. + +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. + +--- + +## Runtime behaviour + +### Sign-in flow + +1. User clicks **Sign in with Google** on `/users/sign_in`. +2. They are redirected to Google's consent screen. If `GOOGLE_ALLOWED_DOMAINS` is non-empty, the `hd` parameter is passed so Google restricts the account chooser at its end too (defense-in-depth). +3. Google redirects back to `/users/auth/google_oauth2/callback`. [Users::OmniauthCallbacksController#google_oauth2](app/controllers/users/omniauth_callbacks_controller.rb) handles the response: + - The `hd` claim is checked against `GOOGLE_ALLOWED_DOMAINS`. Mismatch → redirect to sign-in with the "not permitted" flash. + - The `email` claim is looked up case-insensitively in the `users` table. +4. If a matching user exists: + - If their `provider`/`uid` are unset, they get linked to this Google identity and signed in. + - If they already have a different `uid` linked, sign-in is rejected (defends against account takeover by email collision). +5. If no matching user exists, a new one is JIT-provisioned in the default account, with `role: 'admin'`, the user's Google first/last name, a random unused password, and `confirmed_at: now`. +6. `session[:bypass_otp_for_sso]` is set so the post-login MFA-setup redirect in [DashboardController#maybe_redirect_mfa_setup](app/controllers/dashboard_controller.rb) is skipped. + +### 2FA interaction + +Users who signed in via Google never see the WaboSign OTP prompt — regardless of whether their account has `otp_required_for_login: true`. The reasoning: Google enforces its own MFA, and a second OTP step would be redundant. + +Password users are unaffected — they still see the OTP prompt if they have 2FA enabled. + +If you ever sign out and back in via password, the bypass flag is cleared and the normal OTP path applies. + +### Open access (no allowlist) + +Leaving `GOOGLE_ALLOWED_DOMAINS` empty *enables sign-in for any Google account, including personal Gmail*. A `Rails.logger.warn` is emitted at boot: + +``` +[Wabosign] Google SSO is enabled but GOOGLE_ALLOWED_DOMAINS is empty — any Google account will be permitted to sign in. +``` + +Use this only for demo or single-user deployments. For business deployments, **always** set an allowlist. + +--- + +## Verification + +After deploying: + +1. **Status page** — open as an admin. You should see a green banner: *"Google SSO is enabled. Allowed Workspace domain: `wabo.cc`."* If you see *"Google SSO is not configured"*, your env vars didn't reach the process — check your secret loader. + +2. **Happy path** — open in a private window. Click **Sign in with Google**. Use a Google account whose domain is on the allowlist. You should land on the WaboSign dashboard signed in. If the user didn't already exist in WaboSign, they were just JIT-created in the default account. + +3. **Domain rejection** — repeat with a Google account whose domain is *not* on the allowlist (e.g. a personal `@gmail.com`). You should be redirected back to `/users/sign_in` with the flash: *"Google sign-in failed: this Google account is not permitted to sign in."* + +4. **Password still works** — sign in as a different user with email + password. The flow should be unchanged from before SSO was enabled (still OTP-gated if that user has 2FA on). + +5. **2FA bypass** — turn on WaboSign 2FA for the SSO user via Settings → Profile → Two-Factor Authentication. Sign out. Sign back in via Google. Confirm you go straight to the dashboard without an OTP prompt. + +6. **Spec suite** — `bin/rspec spec/requests/users/omniauth_callbacks_spec.rb` runs five cases: happy path, link existing user, domain rejection, identity collision, 2FA bypass. All should pass. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| No Google button on sign-in page | `GOOGLE_CLIENT_ID` or `GOOGLE_CLIENT_SECRET` is unset, or the app hasn't been restarted since you set them. | Check `bin/rails runner 'puts Wabosign.google_sso_enabled?'` — should print `true`. Restart if needed. | +| `redirect_uri_mismatch` error from Google | The redirect URI registered in Google Cloud Console doesn't match the one WaboSign sends. | Ensure the **Authorized redirect URI** in Google Cloud is exactly `https:///users/auth/google_oauth2/callback` (matching scheme, host, no trailing slash). Update `ENV['HOST']` / `APP_URL` on the WaboSign side if needed. | +| "Google sign-in failed: this Google account is not permitted" for a user whose domain *is* on the allowlist | The Google account is a personal Gmail (no `hd` claim) rather than a Workspace account, or the `hd` claim differs from your allowlist entry (e.g. `googlemail.com` vs `gmail.com`). | Confirm the user is signing in with their Workspace identity. Check the Rails logs around the failure for the actual `hd` value. | +| Identity collision rejection (existing email with different Google uid) | Someone else's Google account already linked to that email, then the user changed their primary Google identity. | Manually unset `provider`/`uid` on that user row via `bin/rails console`: `User.find_by(email: '...').update_columns(provider: nil, uid: nil)`. The next sign-in will re-link. | +| User keeps being prompted for MFA setup after Google sign-in | `FORCE_MFA` is enabled for the account, and the code path missed the bypass. | Confirm `User#signed_in_via_sso?` returns true for that user (run in `rails console`). If it returns false, check that the user has `provider: 'google_oauth2'` and a non-blank `uid` — both are set on first SSO sign-in. | +| Sign-in works but the user lands in an empty/wrong account | More than one `Account` exists in your deployment and the user got assigned to the wrong one by JIT provisioning. | Set `GOOGLE_DEFAULT_ACCOUNT_ID` to pin the target account, or move the user via the Users settings page. | +| Boot log: "any Google account will be permitted to sign in" | Allowlist is empty. | Set `GOOGLE_ALLOWED_DOMAINS` to your Workspace domain(s) and restart. | + +--- + +## Security notes + +- **Domain allowlist enforced server-side.** Google's `hd` parameter on the request is a UX hint — Google may still let unrelated accounts through the consent screen. WaboSign re-checks the `hd` claim in the OAuth response before issuing a session, so a misconfigured Google Cloud consent screen cannot bypass the allowlist. + +- **Identity collision protection.** If a row in `users` already has `provider`/`uid` set, sign-in via a *different* Google uid for the same email is rejected. This blocks "I changed my Workspace identity but kept the email alias" attacks. + +- **Password sign-in is not weakened.** Adding Google SSO does not remove or alter the password flow. Users who never click the Google button can keep using passwords + OTP exactly as before. An attacker who compromises your Google Cloud project can sign in as any allowlisted email — they cannot sign in as users on disallowed domains, nor as users who have no password yet (those don't exist after JIT — every JIT-created user has a random unused password, which is fine because it's not recoverable). + +- **The unused password.** JIT-created users have `password = SecureRandom.hex(32)`. It is never displayed and never resetable through the standard flow (the user has no way to know it). To grant a JIT-only user a real password, use the Devise "forgot password" flow or set one via `bin/rails console`. + +- **No domain-wide pre-revocation.** Removing a user from your Google Workspace does not delete or disable their WaboSign user. Use the Users settings page to archive them, or write a periodic task that scans against Google's Admin SDK. + +--- + +## Code map + +| File | Role | +|---|---| +| [Gemfile](Gemfile) | Adds `omniauth`, `omniauth-google-oauth2`, `omniauth-rails_csrf_protection`. | +| [lib/wabosign.rb](lib/wabosign.rb) | `GOOGLE_*` constants, `google_sso_enabled?`, `google_domain_allowed?`, boot warning. | +| [config/initializers/devise.rb](config/initializers/devise.rb) | Registers `:google_oauth2` Devise OmniAuth strategy when enabled. | +| [app/models/user.rb](app/models/user.rb) | Conditional `:omniauthable`, `from_google_omniauth`, `default_sso_account`, `signed_in_via_sso?`. | +| [config/routes.rb](config/routes.rb) | `devise_for` extended with `omniauth_callbacks`. | +| [app/controllers/users/omniauth_callbacks_controller.rb](app/controllers/users/omniauth_callbacks_controller.rb) | Handles `/users/auth/google_oauth2/callback`. | +| [app/controllers/sessions_controller.rb](app/controllers/sessions_controller.rb) | Clears `session[:bypass_otp_for_sso]` on sign-out. | +| [app/controllers/dashboard_controller.rb](app/controllers/dashboard_controller.rb) | Honours the SSO bypass flag to skip the FORCE_MFA redirect. | +| [app/views/devise/sessions/_omniauthable.html.erb](app/views/devise/sessions/_omniauthable.html.erb) | The Google button on the sign-in page. | +| [app/views/sso_settings/_placeholder.html.erb](app/views/sso_settings/_placeholder.html.erb) | Status panel at `/settings/sso`. | +| `db/migrate/20260515200000_add_omniauth_to_users.rb` | Adds `provider`, `uid`, partial unique index. | +| `public/google_g.svg` | Google "G" mark used by the button. | +| `spec/requests/users/omniauth_callbacks_spec.rb` | Request specs (happy path, link, reject, collision, 2FA bypass). | + +--- + +## Future work + +- **Other IdPs.** To support Okta / Entra / Keycloak, swap `omniauth-google-oauth2` for `omniauth_openid_connect` (generic OIDC) and add per-IdP env vars. The User model JIT logic stays the same shape. +- **Admin UI for credentials.** Move from env vars to an encrypted `EncryptedConfig` record so non-developers can rotate credentials. The existing [SsoSettingsController](app/controllers/sso_settings_controller.rb) already loads a `saml_configs` key — extend with `google_oauth_configs` and a form. +- **Role mapping.** When you add `:editor` / `:viewer` roles, derive them from Google Workspace group claims rather than defaulting every JIT user to `:admin`. +- **Workspace-wide deprovisioning.** Periodic Sidekiq job that uses the Google Admin SDK to check whether each Google-linked WaboSign user still exists in your Workspace, and archives ones that don't. diff --git a/Gemfile b/Gemfile index 4e2fd78e..200e84a6 100644 --- a/Gemfile +++ b/Gemfile @@ -15,6 +15,9 @@ gem 'csv', require: false gem 'csv-safe', require: false gem 'devise' gem 'devise-two-factor' +gem 'omniauth', '~> 2.1' +gem 'omniauth-google-oauth2', '~> 1.2' +gem 'omniauth-rails_csrf_protection', '~> 1.0' gem 'dotenv', require: false gem 'email_typo' gem 'faraday' diff --git a/README.md b/README.md index 200cf184..3db8332e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ WaboSign is a fork of [DocuSeal](https://github.com/docusealco/docuseal) under A - API + Webhooks for integrations - SMS invitations / verification - Bulk send via CSV / XLSX import -- SAML / SSO +- Google Workspace SSO ([setup guide](GOOGLE_SSO.md)) - Conditional fields and formulas - Custom branding (logo, colors, reply-to) - Easy Docker deployment @@ -53,6 +53,10 @@ sudo HOST=sign.example.com docker compose up Make sure your DNS points at the server so Caddy can issue an SSL cert automatically. +## Authentication + +WaboSign ships with email + password (Devise) and TOTP two-factor auth out of the box. Google Workspace SSO can be enabled by setting three environment variables — see [GOOGLE_SSO.md](GOOGLE_SSO.md) for the full operator guide. + ## License WaboSign is distributed under the [GNU Affero General Public License v3.0](LICENSE), with the §7(b) [Additional Terms](LICENSE_ADDITIONAL_TERMS) preserved from upstream. diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 841c20dd..dd37a681 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -28,6 +28,7 @@ class DashboardController < ApplicationController def maybe_redirect_mfa_setup return unless signed_in? return if current_user.otp_required_for_login + return if session[:bypass_otp_for_sso] || current_user.signed_in_via_sso? return if !current_user.otp_required_for_login && !AccountConfig.exists?(value: true, account_id: current_user.account_id, diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 2f94ea4c..0a64c68e 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -22,6 +22,11 @@ class SessionsController < Devise::SessionsController super end + def destroy + session.delete(:bypass_otp_for_sso) + super + end + private def after_sign_in_path_for(...) diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb new file mode 100644 index 00000000..1d8ce94f --- /dev/null +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Users + class OmniauthCallbacksController < Devise::OmniauthCallbacksController + skip_before_action :verify_authenticity_token, raise: false + + def google_oauth2 + user = User.from_google_omniauth(request.env['omniauth.auth']) + + if user&.persisted? && user.active_for_authentication? + # Trust Google's MFA: bypass the WaboSign OTP gate for this session. + session[:bypass_otp_for_sso] = true + sign_in(user, event: :authentication) + set_flash_message(:notice, :signed_in, kind: 'Google') if is_flashing_format? + redirect_to after_sign_in_path_for(user) + else + flash[:alert] = 'Google sign-in failed: this Google account is not permitted to sign in.' + redirect_to new_user_session_path + end + end + + def failure + flash[:alert] = "Google sign-in failed: #{failure_message}" + redirect_to new_user_session_path + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index b80ae769..5acd4eb7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -69,7 +69,9 @@ 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_modules = %i[two_factor_authenticatable recoverable rememberable validatable trackable lockable] + devise_modules << :omniauthable if Wabosign.google_sso_enabled? + devise(*devise_modules, omniauth_providers: [:google_oauth2]) attribute :role, :string, default: ADMIN_ROLE attribute :uuid, :string, default: -> { SecureRandom.uuid } @@ -121,4 +123,47 @@ class User < ApplicationRecord email end end + + def signed_in_via_sso? + provider == 'google_oauth2' && uid.present? + end + + def self.from_google_omniauth(auth) + hd = auth.extra&.raw_info&.respond_to?(:hd) ? auth.extra.raw_info.hd : auth.extra&.raw_info&.dig('hd') + return nil unless Wabosign.google_domain_allowed?(hd) + + email = auth.info.email.to_s.downcase + return nil if email.blank? + + user = find_by('lower(email) = ?', email) + if user + return nil if user.provider.present? && user.uid != auth.uid + + user.update!(provider: 'google_oauth2', uid: auth.uid) if user.provider.blank? + return user + end + + account = default_sso_account + return nil if account.nil? + + create!( + account: account, + email: email, + first_name: auth.info.first_name, + last_name: auth.info.last_name, + role: ADMIN_ROLE, + password: SecureRandom.hex(32), + provider: 'google_oauth2', + uid: auth.uid, + confirmed_at: Time.current + ) + end + + def self.default_sso_account + if Wabosign::GOOGLE_DEFAULT_ACCOUNT_ID.present? + Account.find_by(id: Wabosign::GOOGLE_DEFAULT_ACCOUNT_ID) + else + Account.order(:created_at).first + end + end end diff --git a/app/views/devise/sessions/_omniauthable.html.erb b/app/views/devise/sessions/_omniauthable.html.erb index e69de29b..b5fb0245 100644 --- a/app/views/devise/sessions/_omniauthable.html.erb +++ b/app/views/devise/sessions/_omniauthable.html.erb @@ -0,0 +1,12 @@ +<% if Wabosign.google_sso_enabled? %> +
<%= t('or') %>
+
+ <%= button_to user_google_oauth2_omniauth_authorize_path, + method: :post, + data: { turbo: false }, + class: 'base-button !bg-white !text-base-content border border-base-300 flex items-center justify-center gap-2' do %> + + <%= t('sign_in_with_google') %> + <% end %> +
+<% end %> diff --git a/app/views/sso_settings/_placeholder.html.erb b/app/views/sso_settings/_placeholder.html.erb index 898cd1c7..1604797d 100644 --- a/app/views/sso_settings/_placeholder.html.erb +++ b/app/views/sso_settings/_placeholder.html.erb @@ -1,11 +1,30 @@ -
- <%= svg_icon('info_circle', class: 'w-6 h-6') %> -
-

- <%= t('single_sign_on_with_saml_2_0') %> -

-

- SAML 2.0 SSO requires a SAML library integration (e.g. ruby-saml) that is not bundled with this open-source edition. Encrypted config is stored under the saml_configs key on the account. -

+<% 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/config/initializers/devise.rb b/config/initializers/devise.rb index efa97448..a240bf15 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -334,6 +334,18 @@ Devise.setup do |config| # changed. Defaults to true, so a user is signed in automatically after changing a password. # config.sign_in_after_change_password = true + if Wabosign.google_sso_enabled? + config.omniauth :google_oauth2, + Wabosign::GOOGLE_CLIENT_ID, + Wabosign::GOOGLE_CLIENT_SECRET, + { + scope: 'email,profile', + prompt: 'select_account', + access_type: 'online', + hd: Wabosign::GOOGLE_ALLOWED_DOMAINS.presence + } + end + ActiveSupport.run_load_hooks(:devise_config, config) end # rubocop:enable Metrics/BlockLength diff --git a/config/routes.rb b/config/routes.rb index 09a1d20f..fbdd8531 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,8 +14,12 @@ Rails.application.routes.draw do get 'up' => 'rails/health#show' get 'manifest' => 'pwa#manifest' - devise_for :users, path: '/', only: %i[sessions passwords], - controllers: { sessions: 'sessions', passwords: 'passwords' } + 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 diff --git a/db/migrate/20260515200000_add_omniauth_to_users.rb b/db/migrate/20260515200000_add_omniauth_to_users.rb new file mode 100644 index 00000000..ce63f3e4 --- /dev/null +++ b/db/migrate/20260515200000_add_omniauth_to_users.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOmniauthToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :provider, :string + add_column :users, :uid, :string + add_index :users, %i[provider uid], unique: true, where: 'provider IS NOT NULL' + end +end diff --git a/lib/wabosign.rb b/lib/wabosign.rb index 95571363..38250e2b 100644 --- a/lib/wabosign.rb +++ b/lib/wabosign.rb @@ -14,6 +14,11 @@ 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' elsif ENV['MULTITENANT'] == 'true' @@ -121,4 +126,22 @@ module Wabosign def refresh_default_url_options! @default_url_options = nil end + + def google_sso_enabled? + GOOGLE_CLIENT_ID.present? && GOOGLE_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 + +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.' + ) end diff --git a/public/google_g.svg b/public/google_g.svg new file mode 100644 index 00000000..5400a3ba --- /dev/null +++ b/public/google_g.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/spec/requests/users/omniauth_callbacks_spec.rb b/spec/requests/users/omniauth_callbacks_spec.rb new file mode 100644 index 00000000..18b0bd4d --- /dev/null +++ b/spec/requests/users/omniauth_callbacks_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Google OAuth2 callback', type: :request do + let!(:account) { create(:account) } + + before 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) + stub_const('Wabosign::GOOGLE_DEFAULT_ACCOUNT_ID', nil) + end + + after do + OmniAuth.config.test_mode = false + OmniAuth.config.mock_auth[:google_oauth2] = nil + end + + def stub_google_auth(email:, uid: '1234567890', hd: 'wabo.cc', first_name: 'Test', last_name: 'User') + OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new( + provider: 'google_oauth2', + uid: uid, + info: { email: email, first_name: first_name, last_name: last_name }, + extra: { raw_info: OmniAuth::AuthHash.new(hd: hd) } + ) + end + + describe 'happy path: new email, allowed domain' do + it 'creates the user in the default account and signs them in' do + stub_google_auth(email: 'new.user@wabo.cc') + + expect do + post '/users/auth/google_oauth2/callback' + end.to change(User, :count).by(1) + + user = User.find_by(email: 'new.user@wabo.cc') + expect(user.provider).to eq('google_oauth2') + expect(user.uid).to eq('1234567890') + expect(user.account).to eq(account) + expect(response).to redirect_to(root_path) + end + end + + describe 'existing user with matching email, no provider yet' do + let!(:user) { create(:user, account: account, email: 'existing@wabo.cc') } + + it 'links the Google identity and signs the user in' do + stub_google_auth(email: 'existing@wabo.cc', uid: 'google-uid-99') + + expect do + post '/users/auth/google_oauth2/callback' + end.not_to change(User, :count) + + user.reload + expect(user.provider).to eq('google_oauth2') + expect(user.uid).to eq('google-uid-99') + expect(response).to redirect_to(root_path) + end + end + + describe 'disallowed Workspace domain' do + it 'redirects back to sign-in with a flash' do + stub_google_auth(email: 'outsider@evil.com', hd: 'evil.com') + + expect do + post '/users/auth/google_oauth2/callback' + end.not_to change(User, :count) + + expect(response).to redirect_to(new_user_session_path) + expect(flash[:alert]).to include('not permitted') + end + end + + describe 'identity collision' do + let!(:user) do + create(:user, account: account, email: 'taken@wabo.cc').tap do |u| + u.update_columns(provider: 'google_oauth2', uid: 'original-uid') + end + end + + it 'rejects sign-in when the email is linked to a different Google uid' do + stub_google_auth(email: 'taken@wabo.cc', uid: 'different-uid') + + post '/users/auth/google_oauth2/callback' + + user.reload + expect(user.uid).to eq('original-uid') + expect(response).to redirect_to(new_user_session_path) + end + end + + describe '2FA bypass' do + let!(:user) do + create(:user, account: account, email: '2fa@wabo.cc').tap do |u| + u.update_columns(otp_required_for_login: true, otp_secret: User.generate_otp_secret) + end + end + + it 'signs the user in via Google without prompting for OTP' do + stub_google_auth(email: '2fa@wabo.cc', uid: '2fa-uid') + + post '/users/auth/google_oauth2/callback' + + expect(response).to redirect_to(root_path) + get root_path + expect(response).not_to redirect_to(mfa_setup_path) + end + end +end