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 <noreply@anthropic.com>
pull/687/head
Wabo 1 month ago
parent 2796ddf424
commit ad12ef7fb5

@ -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://<your-host>/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 <https://console.cloud.google.com/apis/credentials>**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 <https://your-host/settings/sso> 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 <https://your-host/users/sign_in> 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://<your-host>/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.

@ -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'

@ -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.

@ -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,

@ -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(...)

@ -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

@ -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

@ -0,0 +1,12 @@
<% if Wabosign.google_sso_enabled? %>
<div class="divider my-4 text-sm opacity-60"><%= t('or') %></div>
<div class="form-control">
<%= 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 %>
<img src="/google_g.svg" alt="" width="20" height="20" class="w-5 h-5">
<span><%= t('sign_in_with_google') %></span>
<% end %>
</div>
<% end %>

@ -1,11 +1,30 @@
<div class="alert">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">
<%= t('single_sign_on_with_saml_2_0') %>
</p>
<p class="text-gray-700">
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.
</p>
<% if Wabosign.google_sso_enabled? %>
<div class="alert alert-success">
<%= svg_icon('discount_check_filled', class: 'w-6 h-6') %>
<div>
<p class="font-bold">Google SSO is enabled</p>
<p class="text-gray-700">
Configured via environment variables.
<% if Wabosign::GOOGLE_ALLOWED_DOMAINS.any? %>
Allowed Workspace domain<%= 's' if Wabosign::GOOGLE_ALLOWED_DOMAINS.size > 1 %>:
<code><%= Wabosign::GOOGLE_ALLOWED_DOMAINS.join(', ') %></code>.
<% else %>
<strong>Warning:</strong> no domain allowlist set &mdash; any Google account may sign in. Set <code>GOOGLE_ALLOWED_DOMAINS</code> to restrict.
<% end %>
</p>
</div>
</div>
</div>
<% else %>
<div class="alert">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">Google SSO is not configured</p>
<p class="text-gray-700">
Set <code>GOOGLE_CLIENT_ID</code>, <code>GOOGLE_CLIENT_SECRET</code>, and <code>GOOGLE_ALLOWED_DOMAINS</code> (comma-separated) and restart the app. The OAuth redirect URI to register in Google Cloud Console is <code><%= "#{root_url}users/auth/google_oauth2/callback" rescue '/users/auth/google_oauth2/callback' %></code>.
</p>
<p class="text-gray-700 mt-2">
SAML 2.0 SSO is not bundled with this open-source edition. To enable it, add <code>ruby-saml</code> and <code>devise-saml-authenticatable</code> and wire the ACS/SLO/metadata routes; encrypted config is stored under the <code>saml_configs</code> key on the account.
</p>
</div>
</div>
<% end %>

@ -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

@ -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

@ -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

@ -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

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<path fill="#4285F4" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
<path fill="#34A853" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
<path fill="#EA4335" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
<path fill="none" d="M0 0h48v48H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 774 B

@ -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
Loading…
Cancel
Save