mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Add configurable multi-provider SSO, SSO-only mode, and JIT controls via auth.yml (#441)
* Add configuration and logic for dynamic SSO provider support and stricter JIT account creation - Introduced `config/auth.yml` for centralized auth configuration and documentation. - Added support for multiple SSO providers, including Google, GitHub, and OpenID Connect. - Implemented stricter JIT SSO account creation modes (`create_and_link` vs `link_only`). - Enabled optional restriction of JIT creation by allowed email domains. - Enhanced OmniAuth initializer for dynamic provider setup and better configurability. - Refined login UI to handle local login disabling and emergency super-admin override. - Updated account creation flow to respect JIT mode and domain checks. - Added tests for SSO account creation, login form visibility, and emergency overrides. # Conflicts: # app/controllers/sessions_controller.rb * remove non-translation * Refactor authentication views to use translation keys and update locale files - Extracted hardcoded strings in `oidc_accounts/link.html.erb` and `sessions/new.html.erb` into translation keys for better localization support. - Added missing translations for English and Spanish in `sessions` and `oidc_accounts` locale files. * Enhance OmniAuth provider configuration and refine local login override logic - Updated OmniAuth initializer to support dynamic provider configuration with `name` and scoped parameters for Google and GitHub. - Improved local login logic to enforce stricter handling of super-admin override when local login is disabled. - Added test for invalid super-admin override credentials. * Document Google sign-in configuration for local development and self-hosted environments --------- Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
This commit is contained in:
4
Gemfile
4
Gemfile
@@ -77,10 +77,12 @@ gem "rqrcode", "~> 3.0"
|
||||
gem "activerecord-import"
|
||||
gem "rubyzip", "~> 2.3"
|
||||
|
||||
# OpenID Connect authentication
|
||||
# OpenID Connect & OAuth authentication
|
||||
gem "omniauth", "~> 2.1"
|
||||
gem "omniauth-rails_csrf_protection"
|
||||
gem "omniauth_openid_connect"
|
||||
gem "omniauth-google-oauth2"
|
||||
gem "omniauth-github"
|
||||
|
||||
# State machines
|
||||
gem "aasm"
|
||||
|
||||
25
Gemfile.lock
25
Gemfile.lock
@@ -391,6 +391,14 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
oauth2 (2.0.18)
|
||||
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, >= 1.1.9)
|
||||
octokit (10.0.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
@@ -398,6 +406,17 @@ GEM
|
||||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-github (2.0.1)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-oauth2 (~> 1.8)
|
||||
omniauth-google-oauth2 (1.2.1)
|
||||
jwt (>= 2.9.2)
|
||||
oauth2 (~> 2.0)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-oauth2 (~> 1.8)
|
||||
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)
|
||||
@@ -649,6 +668,9 @@ GEM
|
||||
skylight (6.0.4)
|
||||
activesupport (>= 5.2.0)
|
||||
smart_properties (1.17.0)
|
||||
snaky_hash (2.0.3)
|
||||
hashie (>= 0.1.0, < 6)
|
||||
version_gem (>= 1.1.8, < 3)
|
||||
sorbet-runtime (0.5.12163)
|
||||
stackprof (0.2.27)
|
||||
stimulus-rails (1.3.4)
|
||||
@@ -692,6 +714,7 @@ GEM
|
||||
vcr (6.3.1)
|
||||
base64
|
||||
vernier (1.8.0)
|
||||
version_gem (1.1.9)
|
||||
view_component (3.23.2)
|
||||
activesupport (>= 5.2.0, < 8.1)
|
||||
concurrent-ruby (~> 1)
|
||||
@@ -769,6 +792,8 @@ DEPENDENCIES
|
||||
mocha
|
||||
octokit
|
||||
omniauth (~> 2.1)
|
||||
omniauth-github
|
||||
omniauth-google-oauth2
|
||||
omniauth-rails_csrf_protection
|
||||
omniauth_openid_connect
|
||||
ostruct
|
||||
|
||||
@@ -13,6 +13,10 @@ class OidcAccountsController < ApplicationController
|
||||
|
||||
@email = @pending_auth["email"]
|
||||
@user_exists = User.exists?(email: @email) if @email.present?
|
||||
|
||||
# Determine whether we should offer JIT account creation for this
|
||||
# pending auth, based on JIT mode and allowed domains.
|
||||
@allow_account_creation = !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(@email)
|
||||
end
|
||||
|
||||
def create_link
|
||||
@@ -77,10 +81,20 @@ class OidcAccountsController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Create user with a secure random password since they're using OIDC
|
||||
email = @pending_auth["email"]
|
||||
|
||||
# Respect global JIT configuration: in link_only mode or when the email
|
||||
# domain is not allowed, block JIT account creation and send the user
|
||||
# back to the login page with a clear message.
|
||||
unless !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email)
|
||||
redirect_to new_session_path, alert: "SSO account creation is disabled. Please contact an administrator."
|
||||
return
|
||||
end
|
||||
|
||||
# Create user with a secure random password since they're using SSO
|
||||
secure_password = SecureRandom.base58(32)
|
||||
@user = User.new(
|
||||
email: @pending_auth["email"],
|
||||
email: email,
|
||||
first_name: @pending_auth["first_name"],
|
||||
last_name: @pending_auth["last_name"],
|
||||
password: secure_password,
|
||||
@@ -92,7 +106,7 @@ class OidcAccountsController < ApplicationController
|
||||
@user.role = :admin
|
||||
|
||||
if @user.save
|
||||
# Create the OIDC identity
|
||||
# Create the OIDC (or other SSO) identity
|
||||
OidcIdentity.create_from_omniauth(
|
||||
build_auth_hash(@pending_auth),
|
||||
@user
|
||||
|
||||
@@ -3,6 +3,7 @@ class PasswordResetsController < ApplicationController
|
||||
|
||||
layout "auth"
|
||||
|
||||
before_action :ensure_password_resets_enabled
|
||||
before_action :set_user_by_token, only: %i[edit update]
|
||||
|
||||
def new
|
||||
@@ -33,6 +34,12 @@ class PasswordResetsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def ensure_password_resets_enabled
|
||||
return if AuthConfig.password_features_enabled?
|
||||
|
||||
redirect_to new_session_path, alert: t("password_resets.disabled")
|
||||
end
|
||||
|
||||
def set_user_by_token
|
||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||
redirect_to new_password_reset_path, alert: t("password_resets.update.invalid_token") unless @user.present?
|
||||
|
||||
@@ -5,24 +5,53 @@ class SessionsController < ApplicationController
|
||||
layout "auth"
|
||||
|
||||
def new
|
||||
demo = demo_config
|
||||
@prefill_demo_credentials = demo_host_match?(demo)
|
||||
|
||||
if @prefill_demo_credentials
|
||||
@email = params[:email].presence || demo["email"]
|
||||
@password = params[:password].presence || demo["password"]
|
||||
else
|
||||
begin
|
||||
demo = Rails.application.config_for(:demo)
|
||||
@prefill_demo_credentials = demo_host_match?(demo)
|
||||
if @prefill_demo_credentials
|
||||
@email = params[:email].presence || demo["email"]
|
||||
@password = params[:password].presence || demo["password"]
|
||||
else
|
||||
@email = params[:email]
|
||||
@password = params[:password]
|
||||
end
|
||||
rescue RuntimeError, Errno::ENOENT, Psych::SyntaxError
|
||||
# Demo config file missing or malformed - disable demo credential prefilling
|
||||
@prefill_demo_credentials = false
|
||||
@email = params[:email]
|
||||
@password = params[:password]
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
if user = User.authenticate_by(email: params[:email], password: params[:password])
|
||||
user = nil
|
||||
|
||||
if AuthConfig.local_login_enabled?
|
||||
user = User.authenticate_by(email: params[:email], password: params[:password])
|
||||
else
|
||||
# Local login is disabled. Only allow attempts when an emergency super-admin
|
||||
# override is enabled and the email belongs to a super-admin.
|
||||
if AuthConfig.local_admin_override_enabled?
|
||||
candidate = User.find_by(email: params[:email])
|
||||
unless candidate&.super_admin?
|
||||
redirect_to new_session_path, alert: t("sessions.create.local_login_disabled")
|
||||
return
|
||||
end
|
||||
|
||||
user = User.authenticate_by(email: params[:email], password: params[:password])
|
||||
else
|
||||
redirect_to new_session_path, alert: t("sessions.create.local_login_disabled")
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if user
|
||||
if user.otp_required?
|
||||
log_super_admin_override_login(user)
|
||||
session[:mfa_user_id] = user.id
|
||||
redirect_to verify_mfa_path
|
||||
else
|
||||
log_super_admin_override_login(user)
|
||||
@session = create_session_for(user)
|
||||
redirect_to root_path
|
||||
end
|
||||
@@ -85,4 +114,20 @@ class SessionsController < ApplicationController
|
||||
def set_session
|
||||
@session = Current.user.sessions.find(params[:id])
|
||||
end
|
||||
|
||||
def log_super_admin_override_login(user)
|
||||
# Only log when local login is globally disabled but an emergency
|
||||
# super-admin override is enabled.
|
||||
return if AuthConfig.local_login_enabled?
|
||||
return unless AuthConfig.local_admin_override_enabled?
|
||||
return unless user&.super_admin?
|
||||
|
||||
Rails.logger.info("[AUTH] Super admin override login: user_id=#{user.id} email=#{user.email}")
|
||||
end
|
||||
|
||||
def demo_host_match?(demo)
|
||||
return false unless demo.present? && demo["hosts"].present?
|
||||
|
||||
demo["hosts"].include?(request.host)
|
||||
end
|
||||
end
|
||||
|
||||
80
app/models/auth_config.rb
Normal file
80
app/models/auth_config.rb
Normal file
@@ -0,0 +1,80 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AuthConfig
|
||||
class << self
|
||||
def local_login_enabled?
|
||||
# Default to true if not configured to preserve existing behavior.
|
||||
value = Rails.configuration.x.auth.local_login_enabled
|
||||
value.nil? ? true : !!value
|
||||
end
|
||||
|
||||
def local_admin_override_enabled?
|
||||
!!Rails.configuration.x.auth.local_admin_override_enabled
|
||||
end
|
||||
|
||||
# When the local login form should be visible on the login page.
|
||||
# - true when local login is enabled for everyone
|
||||
# - true when admin override is enabled (super-admin only backend guard)
|
||||
# - false only in pure SSO-only mode
|
||||
def local_login_form_visible?
|
||||
local_login_enabled? || local_admin_override_enabled?
|
||||
end
|
||||
|
||||
# When password-related features (e.g., password reset link) should be
|
||||
# visible. These are disabled whenever local login is turned off, even if
|
||||
# an admin override is configured.
|
||||
def password_features_enabled?
|
||||
local_login_enabled?
|
||||
end
|
||||
|
||||
# Backend check to determine if a given user is allowed to authenticate via
|
||||
# local email/password credentials.
|
||||
#
|
||||
# - If local login is enabled, all users may authenticate locally (even if
|
||||
# the email does not map to a user, preserving existing error semantics).
|
||||
# - If local login is disabled but admin override is enabled, only
|
||||
# super-admins may authenticate locally.
|
||||
# - If both are disabled, local login is blocked for everyone.
|
||||
def local_login_allowed_for?(user)
|
||||
# When local login is globally enabled, everyone can attempt to log in
|
||||
# and we fall back to invalid credentials for bad email/password combos.
|
||||
return true if local_login_enabled?
|
||||
|
||||
# From here on, local login is disabled except for potential overrides.
|
||||
return false unless user
|
||||
|
||||
return user.super_admin? if local_admin_override_enabled?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def jit_link_only?
|
||||
Rails.configuration.x.auth.jit_mode.to_s == "link_only"
|
||||
end
|
||||
|
||||
def allowed_oidc_domains
|
||||
Rails.configuration.x.auth.allowed_oidc_domains || []
|
||||
end
|
||||
|
||||
# Returns true if the given email is allowed for JIT SSO account creation
|
||||
# under the configured domain restrictions.
|
||||
#
|
||||
# - If no domains are configured, all emails are allowed (current behavior).
|
||||
# - If domains are configured and email is blank, we treat it as not
|
||||
# allowed for creation to avoid silently creating accounts without a
|
||||
# verifiable domain.
|
||||
def allowed_oidc_domain?(email)
|
||||
domains = allowed_oidc_domains
|
||||
return true if domains.empty?
|
||||
|
||||
return false if email.blank?
|
||||
|
||||
domain = email.split("@").last.to_s.downcase
|
||||
domains.map(&:downcase).include?(domain)
|
||||
end
|
||||
|
||||
def sso_providers
|
||||
Rails.configuration.x.auth.sso_providers || []
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -53,14 +53,20 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render DS::Button.new(
|
||||
text: "Create Account",
|
||||
href: create_user_oidc_account_path,
|
||||
full_width: true,
|
||||
variant: :primary,
|
||||
method: :post,
|
||||
data: { turbo: false }
|
||||
) %>
|
||||
<% if @allow_account_creation %>
|
||||
<%= render DS::Button.new(
|
||||
text: "Create Account",
|
||||
href: create_user_oidc_account_path,
|
||||
full_width: true,
|
||||
variant: :primary,
|
||||
method: :post,
|
||||
data: { turbo: false }
|
||||
) %>
|
||||
<% else %>
|
||||
<p class="text-xs text-secondary">
|
||||
<%= t("oidc_accounts.link.account_creation_disabled") %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -16,45 +16,77 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= styled_form_with url: sessions_path, class: "space-y-4", data: { turbo: false } do |form| %>
|
||||
<%= form.email_field :email,
|
||||
label: t(".email"),
|
||||
autofocus: false,
|
||||
autocomplete: "email",
|
||||
required: "required",
|
||||
placeholder: t(".email_placeholder"),
|
||||
value: @email %>
|
||||
<% if AuthConfig.local_login_form_visible? %>
|
||||
<%= styled_form_with url: sessions_path, class: "space-y-4", data: { turbo: false } do |form| %>
|
||||
<%= form.email_field :email,
|
||||
label: t(".email"),
|
||||
autofocus: false,
|
||||
autocomplete: "email",
|
||||
required: "required",
|
||||
placeholder: t(".email_placeholder"),
|
||||
value: @email %>
|
||||
|
||||
<%= form.password_field :password,
|
||||
label: t(".password"),
|
||||
required: "required",
|
||||
placeholder: t(".password_placeholder"),
|
||||
value: @password %>
|
||||
<%= form.password_field :password,
|
||||
label: t(".password"),
|
||||
required: "required",
|
||||
placeholder: t(".password_placeholder"),
|
||||
value: @password %>
|
||||
|
||||
<%= form.submit t(".submit") %>
|
||||
<%= form.submit t(".submit") %>
|
||||
<% end %>
|
||||
|
||||
<% unless AuthConfig.local_login_enabled? %>
|
||||
<p class="mt-2 text-xs text-secondary text-center">
|
||||
<%= t(".local_login_admin_only") %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<% if AuthConfig.password_features_enabled? %>
|
||||
<div class="mt-6 text-center">
|
||||
<%= link_to t(".forgot_password"), new_password_reset_path, class: "font-medium text-sm text-primary hover:underline transition" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<%= link_to t(".forgot_password"), new_password_reset_path, class: "font-medium text-sm text-primary hover:underline transition" %>
|
||||
</div>
|
||||
<% providers = AuthConfig.sso_providers %>
|
||||
|
||||
<% if Rails.configuration.x.auth.oidc_enabled %>
|
||||
<div class="mt-6 text-center">
|
||||
<%= button_to "/auth/openid_connect", method: :post, form: { data: { turbo: false } }, class: "gsi-material-button" do %>
|
||||
<div class="gsi-material-button-state"></div>
|
||||
<div class="gsi-material-button-content-wrapper">
|
||||
<div class="gsi-material-button-icon">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" xmlns:xlink="http://www.w3.org/1999/xlink" style="display: block;">
|
||||
<path fill="#EA4335" 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>
|
||||
<path fill="#4285F4" 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>
|
||||
<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>
|
||||
<path fill="#34A853" 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>
|
||||
<path fill="none" d="M0 0h48v48H0z"></path>
|
||||
</svg>
|
||||
<% if providers.any? %>
|
||||
<div class="mt-6 space-y-3">
|
||||
<% providers.each do |provider| %>
|
||||
<% provider_id = provider[:id].to_s %>
|
||||
<% provider_name = provider[:name].to_s %>
|
||||
|
||||
<% if provider_id == "google" || provider[:strategy].to_s == "google_oauth2" %>
|
||||
<div class="text-center">
|
||||
<%= button_to "/auth/#{provider_name}", method: :post, form: { data: { turbo: false } }, class: "gsi-material-button w-full" do %>
|
||||
<div class="gsi-material-button-state"></div>
|
||||
<div class="gsi-material-button-content-wrapper">
|
||||
<div class="gsi-material-button-icon">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" xmlns:xlink="http://www.w3.org/1999/xlink" style="display: block;">
|
||||
<path fill="#EA4335" 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>
|
||||
<path fill="#4285F4" 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>
|
||||
<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>
|
||||
<path fill="#34A853" 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>
|
||||
<path fill="none" d="M0 0h48v48H0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="gsi-material-button-contents"><%= provider[:label].presence || t(".google_auth_connect") %></span>
|
||||
<span style="display: none;"><%= provider[:label].presence || t(".google_auth_connect") %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<span class="gsi-material-button-contents"><%= t(".google_auth_connect") %></span>
|
||||
<span style="display: none;"><%= t(".google_auth_connect") %></span>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= button_to "/auth/#{provider_name}", method: :post, form: { data: { turbo: false } }, class: "w-full inline-flex items-center justify-center gap-2 rounded-md border border-secondary bg-container px-4 py-2 text-sm font-medium text-primary hover:bg-secondary transition" do %>
|
||||
<% if provider[:icon].present? %>
|
||||
<%= icon provider[:icon], size: "sm" %>
|
||||
<% end %>
|
||||
<span><%= provider[:label].presence || provider[:name].to_s.titleize %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% elsif !AuthConfig.local_login_form_visible? %>
|
||||
<div class="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
<%= t(".no_auth_methods_enabled") %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
55
config/auth.yml
Normal file
55
config/auth.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
default: &default
|
||||
local_login:
|
||||
# When false, local email/password login is disabled for all users unless
|
||||
# AUTH_LOCAL_ADMIN_OVERRIDE_ENABLED is true and the user is a super admin.
|
||||
enabled: <%= ENV.fetch("AUTH_LOCAL_LOGIN_ENABLED", "true") == "true" %>
|
||||
|
||||
# When true and local_login.enabled is false, allow super admins to use
|
||||
# local login as an emergency override. Regular users remain SSO-only.
|
||||
admin_override_enabled: <%= ENV.fetch("AUTH_LOCAL_ADMIN_OVERRIDE_ENABLED", "false") == "true" %>
|
||||
|
||||
jit:
|
||||
# Controls behavior when a user signs in via SSO and no OIDC identity exists.
|
||||
# - "create_and_link" (default): create a new user + family when no match exists
|
||||
# - "link_only": require an existing user; block JIT creation
|
||||
mode: <%= ENV.fetch("AUTH_JIT_MODE", "create_and_link") %>
|
||||
|
||||
# Optional comma-separated list of domains (e.g. "example.com,corp.com").
|
||||
# When non-empty, JIT SSO account creation is only allowed for these domains.
|
||||
# When empty, all domains are allowed (current behavior).
|
||||
allowed_oidc_domains: <%= ENV.fetch("ALLOWED_OIDC_DOMAINS", "") %>
|
||||
|
||||
providers:
|
||||
# Generic OpenID Connect provider (e.g., Keycloak, Authentik, other OIDC issuers).
|
||||
# This maps to the existing :openid_connect OmniAuth strategy and keeps
|
||||
# backwards-compatible behavior for self-hosted setups using OIDC_* env vars.
|
||||
- id: "oidc"
|
||||
strategy: "openid_connect"
|
||||
name: "openid_connect"
|
||||
label: <%= ENV.fetch("OIDC_BUTTON_LABEL", "Sign in with OpenID Connect") %>
|
||||
icon: <%= ENV.fetch("OIDC_BUTTON_ICON", "key") %>
|
||||
|
||||
# Optional Google OAuth provider. Requires the omniauth-google-oauth2 gem
|
||||
# and GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET env vars.
|
||||
- id: "google"
|
||||
strategy: "google_oauth2"
|
||||
name: "google_oauth2"
|
||||
label: <%= ENV.fetch("GOOGLE_BUTTON_LABEL", "Sign in with Google") %>
|
||||
icon: <%= ENV.fetch("GOOGLE_BUTTON_ICON", "google") %>
|
||||
|
||||
# Optional GitHub OAuth provider. Requires the omniauth-github gem and
|
||||
# GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET env vars.
|
||||
- id: "github"
|
||||
strategy: "github"
|
||||
name: "github"
|
||||
label: <%= ENV.fetch("GITHUB_BUTTON_LABEL", "Sign in with GitHub") %>
|
||||
icon: <%= ENV.fetch("GITHUB_BUTTON_ICON", "github") %>
|
||||
|
||||
development:
|
||||
<<: *default
|
||||
|
||||
test:
|
||||
<<: *default
|
||||
|
||||
production:
|
||||
<<: *default
|
||||
26
config/initializers/auth.rb
Normal file
26
config/initializers/auth.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Rails.configuration.x.auth ||= ActiveSupport::OrderedOptions.new
|
||||
|
||||
begin
|
||||
raw_auth_config = Rails.application.config_for(:auth)
|
||||
rescue RuntimeError, Errno::ENOENT, Psych::SyntaxError => e
|
||||
Rails.logger.warn("Auth config not loaded: #{e.class} - #{e.message}")
|
||||
raw_auth_config = {}
|
||||
end
|
||||
|
||||
auth_config = raw_auth_config.deep_symbolize_keys
|
||||
|
||||
Rails.configuration.x.auth.local_login_enabled = auth_config.dig(:local_login, :enabled)
|
||||
Rails.configuration.x.auth.local_admin_override_enabled = auth_config.dig(:local_login, :admin_override_enabled)
|
||||
|
||||
Rails.configuration.x.auth.jit_mode = auth_config.dig(:jit, :mode) || "create_and_link"
|
||||
|
||||
raw_domains = auth_config.dig(:jit, :allowed_oidc_domains).to_s
|
||||
Rails.configuration.x.auth.allowed_oidc_domains = raw_domains.split(",").map(&:strip).reject(&:empty?)
|
||||
|
||||
Rails.configuration.x.auth.providers = (auth_config[:providers] || [])
|
||||
|
||||
# These will be populated by the OmniAuth initializer once providers are
|
||||
# successfully registered.
|
||||
Rails.configuration.x.auth.sso_providers ||= []
|
||||
@@ -2,27 +2,75 @@
|
||||
|
||||
require "omniauth/rails_csrf_protection"
|
||||
|
||||
# Configure OmniAuth for production or test environments
|
||||
# In test mode, OmniAuth will use mock data instead of real provider configuration
|
||||
required_env = %w[OIDC_ISSUER OIDC_CLIENT_ID OIDC_CLIENT_SECRET OIDC_REDIRECT_URI]
|
||||
missing = required_env.select { |k| ENV[k].blank? }
|
||||
if missing.empty? || Rails.env.test?
|
||||
Rails.application.config.middleware.use OmniAuth::Builder do
|
||||
provider :openid_connect,
|
||||
name: :openid_connect,
|
||||
scope: %i[openid email profile],
|
||||
response_type: :code,
|
||||
issuer: ENV["OIDC_ISSUER"].to_s.strip || "https://test.example.com",
|
||||
discovery: true,
|
||||
pkce: true,
|
||||
client_options: {
|
||||
identifier: ENV["OIDC_CLIENT_ID"] || "test_client_id",
|
||||
secret: ENV["OIDC_CLIENT_SECRET"] || "test_client_secret",
|
||||
redirect_uri: ENV["OIDC_REDIRECT_URI"] || "http://test.example.com/callback"
|
||||
}
|
||||
Rails.configuration.x.auth.oidc_enabled = false
|
||||
Rails.configuration.x.auth.sso_providers ||= []
|
||||
|
||||
Rails.application.config.middleware.use OmniAuth::Builder do
|
||||
(Rails.configuration.x.auth.providers || []).each do |raw_cfg|
|
||||
cfg = raw_cfg.deep_symbolize_keys
|
||||
strategy = cfg[:strategy].to_s
|
||||
name = (cfg[:name] || cfg[:id]).to_s
|
||||
|
||||
case strategy
|
||||
when "openid_connect"
|
||||
required_env = %w[OIDC_ISSUER OIDC_CLIENT_ID OIDC_CLIENT_SECRET OIDC_REDIRECT_URI]
|
||||
enabled = Rails.env.test? || required_env.all? { |k| ENV[k].present? }
|
||||
next unless enabled
|
||||
|
||||
issuer = (ENV["OIDC_ISSUER"].presence || "https://test.example.com").to_s.strip
|
||||
client_id = ENV["OIDC_CLIENT_ID"].presence || "test_client_id"
|
||||
client_secret = ENV["OIDC_CLIENT_SECRET"].presence || "test_client_secret"
|
||||
redirect_uri = ENV["OIDC_REDIRECT_URI"].presence || "http://test.example.com/callback"
|
||||
|
||||
provider :openid_connect,
|
||||
name: name.to_sym,
|
||||
scope: %i[openid email profile],
|
||||
response_type: :code,
|
||||
issuer: issuer,
|
||||
discovery: true,
|
||||
pkce: true,
|
||||
client_options: {
|
||||
identifier: client_id,
|
||||
secret: client_secret,
|
||||
redirect_uri: redirect_uri
|
||||
}
|
||||
|
||||
Rails.configuration.x.auth.oidc_enabled = true
|
||||
Rails.configuration.x.auth.sso_providers << cfg.merge(name: name)
|
||||
|
||||
when "google_oauth2"
|
||||
client_id = ENV["GOOGLE_OAUTH_CLIENT_ID"].presence || (Rails.env.test? ? "test_client_id" : nil)
|
||||
client_secret = ENV["GOOGLE_OAUTH_CLIENT_SECRET"].presence || (Rails.env.test? ? "test_client_secret" : nil)
|
||||
next unless client_id.present? && client_secret.present?
|
||||
|
||||
provider :google_oauth2,
|
||||
client_id,
|
||||
client_secret,
|
||||
{
|
||||
name: name.to_sym,
|
||||
scope: "userinfo.email,userinfo.profile"
|
||||
}
|
||||
|
||||
Rails.configuration.x.auth.sso_providers << cfg.merge(name: name)
|
||||
|
||||
when "github"
|
||||
client_id = ENV["GITHUB_CLIENT_ID"].presence || (Rails.env.test? ? "test_client_id" : nil)
|
||||
client_secret = ENV["GITHUB_CLIENT_SECRET"].presence || (Rails.env.test? ? "test_client_secret" : nil)
|
||||
next unless client_id.present? && client_secret.present?
|
||||
|
||||
provider :github,
|
||||
client_id,
|
||||
client_secret,
|
||||
{
|
||||
name: name.to_sym,
|
||||
scope: "user:email"
|
||||
}
|
||||
|
||||
Rails.configuration.x.auth.sso_providers << cfg.merge(name: name)
|
||||
end
|
||||
end
|
||||
Rails.configuration.x.auth.oidc_enabled = true
|
||||
else
|
||||
Rails.logger.warn("OIDC not enabled: missing env vars: #{missing.join(', ')}")
|
||||
Rails.configuration.x.auth.oidc_enabled = false
|
||||
end
|
||||
|
||||
if Rails.configuration.x.auth.sso_providers.empty?
|
||||
Rails.logger.warn("No SSO providers enabled; check auth.yml / ENV configuration")
|
||||
end
|
||||
|
||||
5
config/locales/views/oidc_accounts/en.yml
Normal file
5
config/locales/views/oidc_accounts/en.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
en:
|
||||
oidc_accounts:
|
||||
link:
|
||||
account_creation_disabled: New account creation via single sign-on is disabled. Please contact an administrator to create your account.
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
en:
|
||||
password_resets:
|
||||
disabled: Password reset via Sure is disabled. Please reset your password through your identity provider.
|
||||
edit:
|
||||
title: Reset password
|
||||
new:
|
||||
|
||||
@@ -3,6 +3,7 @@ en:
|
||||
sessions:
|
||||
create:
|
||||
invalid_credentials: Invalid email or password.
|
||||
local_login_disabled: Local password login is disabled. Please use single sign-on.
|
||||
destroy:
|
||||
logout_successful: You have signed out successfully.
|
||||
openid_connect:
|
||||
@@ -19,5 +20,7 @@ en:
|
||||
password_placeholder: Enter your password
|
||||
openid_connect: Sign in with OpenID Connect
|
||||
google_auth_connect: Sign in with Google
|
||||
local_login_admin_only: Local login is restricted to administrators.
|
||||
no_auth_methods_enabled: No authentication methods are currently enabled. Please contact an administrator.
|
||||
demo_banner_title: "Demo Mode Active"
|
||||
demo_banner_message: "This is a demonstration environment. Login credentials have been pre-filled for your convenience. Please do not enter real or sensitive information."
|
||||
|
||||
@@ -3,6 +3,7 @@ es:
|
||||
sessions:
|
||||
create:
|
||||
invalid_credentials: Correo electrónico o contraseña inválidos.
|
||||
local_login_disabled: El inicio de sesión con contraseña local está deshabilitado. Utiliza el inicio de sesión único (SSO).
|
||||
destroy:
|
||||
logout_successful: Has cerrado sesión con éxito.
|
||||
openid_connect:
|
||||
@@ -19,3 +20,5 @@ es:
|
||||
password_placeholder: Introduce tu contraseña
|
||||
openid_connect: Inicia sesión con OpenID Connect
|
||||
google_auth_connect: Inicia sesión con Google
|
||||
local_login_admin_only: El inicio de sesión local está restringido a administradores.
|
||||
no_auth_methods_enabled: No hay métodos de autenticación habilitados actualmente. Ponte en contacto con un administrador.
|
||||
|
||||
@@ -1,43 +1,252 @@
|
||||
# Configuring OpenID Connect with Google
|
||||
# Configuring OpenID Connect and SSO providers
|
||||
|
||||
This guide shows how to enable OpenID Connect (OIDC) logins for Sure using Google as the identity provider.
|
||||
This guide shows how to enable OpenID Connect (OIDC) and other single sign-on (SSO) providers for Sure using Google, GitHub, or another OIDC‑compatible identity provider (e.g. Keycloak, Authentik).
|
||||
|
||||
## 1. Create a Google Cloud project
|
||||
It also documents the new `config/auth.yml` and environment variables that control:
|
||||
|
||||
1. Visit [https://console.cloud.google.com](https://console.cloud.google.com) and sign in.
|
||||
- Whether local email/password login is enabled
|
||||
- Whether an emergency super‑admin override is allowed
|
||||
- How JIT SSO account creation behaves (create vs link‑only, allowed domains)
|
||||
- Which SSO providers appear as buttons on the login page
|
||||
|
||||
---
|
||||
|
||||
## 1. Create an OIDC / OAuth client in your IdP
|
||||
|
||||
For Google, follow the standard OAuth2 client setup:
|
||||
|
||||
1. Visit <https://console.cloud.google.com> and sign in.
|
||||
2. Create a new project or select an existing one.
|
||||
3. Configure the OAuth consent screen under **APIs & Services > OAuth consent screen**.
|
||||
4. Go to **APIs & Services > Credentials** and click **Create Credentials > OAuth client ID**.
|
||||
5. Select **Web application** as the application type.
|
||||
6. Add an authorized redirect URI. For local development:
|
||||
|
||||
## 2. Configure the OAuth consent screen
|
||||
|
||||
1. Navigate to **APIs & Services > OAuth consent screen**.
|
||||
2. Choose **External** and follow the prompts to configure the consent screen.
|
||||
3. Add your Google account as a test user.
|
||||
|
||||
## 3. Create OAuth client credentials
|
||||
|
||||
1. Go to **APIs & Services > Credentials** and click **Create Credentials > OAuth client ID**.
|
||||
2. Select **Web application** as the application type.
|
||||
3. Add an authorized redirect URI. For local development use:
|
||||
```
|
||||
http://localhost:3000/auth/openid_connect/callback
|
||||
```
|
||||
Replace with your domain for production, e.g.:
|
||||
|
||||
For production, use your domain:
|
||||
|
||||
```
|
||||
https://yourdomain.com/auth/openid_connect/callback
|
||||
```
|
||||
4. After creating the credentials, copy the **Client ID** and **Client Secret**.
|
||||
|
||||
## 4. Configure Sure
|
||||
7. After creating the credentials, copy the **Client ID** and **Client Secret**.
|
||||
|
||||
For other OIDC providers (e.g. Keycloak), create a client with a redirect URI of:
|
||||
|
||||
```
|
||||
https://yourdomain.com/auth/openid_connect/callback
|
||||
```
|
||||
|
||||
and ensure that the `openid`, `email`, and `profile` scopes are available.
|
||||
|
||||
---
|
||||
|
||||
## 2. Configure Sure: OIDC core settings
|
||||
|
||||
Set the following environment variables in your deployment (e.g. `.env`, `docker-compose`, or hosting platform):
|
||||
|
||||
```bash
|
||||
OIDC_ISSUER="https://accounts.google.com"
|
||||
OIDC_CLIENT_ID="your-google-client-id"
|
||||
OIDC_CLIENT_SECRET="your-google-client-secret"
|
||||
OIDC_ISSUER="https://accounts.google.com" # or your Keycloak/AuthentiK issuer URL
|
||||
OIDC_CLIENT_ID="your-oidc-client-id"
|
||||
OIDC_CLIENT_SECRET="your-oidc-client-secret"
|
||||
OIDC_REDIRECT_URI="https://yourdomain.com/auth/openid_connect/callback"
|
||||
```
|
||||
|
||||
Restart the application after saving the variables.
|
||||
|
||||
The user can now sign in from the login page using the **Sign in with OpenID Connect** link. Google must report the user's email as verified and it must match the email on the account.
|
||||
When OIDC is correctly configured, users can sign in from the login page using the **Sign in with OpenID Connect** button (label can be customized, see below). The IdP must report the user's email as verified, and it must match an existing user or be allowed for JIT creation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Auth configuration (`config/auth.yml`)
|
||||
|
||||
Authentication behavior is driven by `config/auth.yml`, which can be overridden via environment variables.
|
||||
|
||||
### 3.1 Structure
|
||||
|
||||
```yaml
|
||||
default: &default
|
||||
local_login:
|
||||
enabled: <%= ENV.fetch("AUTH_LOCAL_LOGIN_ENABLED", "true") == "true" %>
|
||||
admin_override_enabled: <%= ENV.fetch("AUTH_LOCAL_ADMIN_OVERRIDE_ENABLED", "false") == "true" %>
|
||||
|
||||
jit:
|
||||
mode: <%= ENV.fetch("AUTH_JIT_MODE", "create_and_link") %>
|
||||
allowed_oidc_domains: <%= ENV.fetch("ALLOWED_OIDC_DOMAINS", "") %>
|
||||
|
||||
providers:
|
||||
- id: "oidc"
|
||||
strategy: "openid_connect"
|
||||
name: "openid_connect"
|
||||
label: <%= ENV.fetch("OIDC_BUTTON_LABEL", "Sign in with OpenID Connect") %>
|
||||
icon: <%= ENV.fetch("OIDC_BUTTON_ICON", "key") %>
|
||||
|
||||
- id: "google"
|
||||
strategy: "google_oauth2"
|
||||
name: "google_oauth2"
|
||||
label: <%= ENV.fetch("GOOGLE_BUTTON_LABEL", "Sign in with Google") %>
|
||||
icon: <%= ENV.fetch("GOOGLE_BUTTON_ICON", "google") %>
|
||||
|
||||
- id: "github"
|
||||
strategy: "github"
|
||||
name: "github"
|
||||
label: <%= ENV.fetch("GITHUB_BUTTON_LABEL", "Sign in with GitHub") %>
|
||||
icon: <%= ENV.fetch("GITHUB_BUTTON_ICON", "github") %>
|
||||
|
||||
development:
|
||||
<<: *default
|
||||
|
||||
test:
|
||||
<<: *default
|
||||
|
||||
production:
|
||||
<<: *default
|
||||
```
|
||||
|
||||
### 3.2 Local login flags
|
||||
|
||||
- `AUTH_LOCAL_LOGIN_ENABLED` (default: `true`)
|
||||
- When `true`, the login page shows the email/password form and "Forgot password" link.
|
||||
- When `false`, local login is disabled for all users unless the admin override flag is enabled.
|
||||
- When `false`, password reset via Sure is also disabled (users must reset via the IdP).
|
||||
|
||||
- `AUTH_LOCAL_ADMIN_OVERRIDE_ENABLED` (default: `false`)
|
||||
- When `true` and `AUTH_LOCAL_LOGIN_ENABLED=false`, super‑admin users can still log in with local passwords.
|
||||
- Regular users remain SSO‑only.
|
||||
- The login form is visible with a note: "Local login is restricted to administrators."
|
||||
- Successful override logins are logged in the Rails logs.
|
||||
|
||||
### 3.3 JIT user creation
|
||||
|
||||
- `AUTH_JIT_MODE` (default: `create_and_link`)
|
||||
- `create_and_link`: the current behavior.
|
||||
- If the SSO identity is new and the email does not match an existing user, Sure will offer to create a new account (subject to domain checks below).
|
||||
- `link_only`: stricter behavior.
|
||||
- New SSO identities can only be linked to existing users; JIT account creation is disabled.
|
||||
- Users without an existing account are sent back to the login page with an explanatory message.
|
||||
|
||||
- `ALLOWED_OIDC_DOMAINS`
|
||||
- Optional comma‑separated list of domains (e.g. `example.com,corp.com`).
|
||||
- When **empty**, JIT SSO account creation is allowed for any verified email.
|
||||
- When **set**, JIT SSO account creation is only allowed if the email domain is in this list.
|
||||
- Applies uniformly to all SSO providers (OIDC, Google, GitHub, etc.) that supply an email.
|
||||
|
||||
### 3.4 Providers and buttons
|
||||
|
||||
Each provider entry in `providers` configures an SSO button on the login page:
|
||||
|
||||
- `id`: a short identifier used in docs and conditionals.
|
||||
- `strategy`: the OmniAuth strategy (`openid_connect`, `google_oauth2`, `github`, ...).
|
||||
- `name`: the OmniAuth provider name, which determines the `/auth/:provider` path.
|
||||
- `label`: button text shown to users.
|
||||
- `icon`: optional icon name passed to the Sure `icon` helper (e.g. `key`, `google`, `github`).
|
||||
|
||||
Special behavior:
|
||||
|
||||
- Providers with `id: "google"` or `strategy: "google_oauth2"` render a Google‑branded sign‑in button.
|
||||
- Other providers (e.g. OIDC/Keycloak, GitHub) render a generic styled button with the configured label and icon.
|
||||
|
||||
#### Enabling Google sign‑in (local dev / self‑hosted)
|
||||
|
||||
The Google button is only shown when the Google provider is actually registered by OmniAuth at boot.
|
||||
|
||||
To enable Google:
|
||||
|
||||
1. Ensure the Google provider exists in `config/auth.yml` under `providers:` with `strategy: "google_oauth2"`.
|
||||
2. Set these environment variables (for example in `.env.local`, Docker Compose, or your process manager):
|
||||
|
||||
- `GOOGLE_OAUTH_CLIENT_ID`
|
||||
- `GOOGLE_OAUTH_CLIENT_SECRET`
|
||||
|
||||
If either is missing, Sure will skip registering the Google provider and the Google button will not appear on the login page.
|
||||
|
||||
3. In your Google Cloud OAuth client configuration, add an authorized redirect URI that matches the host you use in dev.
|
||||
|
||||
Common local values:
|
||||
|
||||
- `http://localhost:3000/auth/google_oauth2/callback`
|
||||
- `http://127.0.0.1:3000/auth/google_oauth2/callback`
|
||||
|
||||
If you customize the provider `name` in `config/auth.yml`, the callback path changes accordingly:
|
||||
|
||||
- `http://localhost:3000/auth/<provider_name>/callback`
|
||||
|
||||
---
|
||||
|
||||
## 4. Example configurations
|
||||
|
||||
### 4.1 Default hybrid (local + SSO)
|
||||
|
||||
This is effectively the default configuration:
|
||||
|
||||
```bash
|
||||
AUTH_LOCAL_LOGIN_ENABLED=true
|
||||
AUTH_LOCAL_ADMIN_OVERRIDE_ENABLED=false
|
||||
AUTH_JIT_MODE=create_and_link
|
||||
ALLOWED_OIDC_DOMAINS="" # or unset
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Users can sign in with email/password or via any configured SSO providers.
|
||||
- JIT SSO account creation is allowed for all verified email domains.
|
||||
|
||||
### 4.2 Pure SSO‑only
|
||||
|
||||
Disable local login entirely:
|
||||
|
||||
```bash
|
||||
AUTH_LOCAL_LOGIN_ENABLED=false
|
||||
AUTH_LOCAL_ADMIN_OVERRIDE_ENABLED=false
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Email/password form and "Forgot password" link are hidden.
|
||||
- `POST /sessions` with local credentials is blocked and redirected with a message.
|
||||
- Password reset routes are disabled (redirect to the login page with an IdP message).
|
||||
|
||||
### 4.3 SSO‑only with emergency admin override
|
||||
|
||||
Allow only super‑admin users to log in locally during IdP outages:
|
||||
|
||||
```bash
|
||||
AUTH_LOCAL_LOGIN_ENABLED=false
|
||||
AUTH_LOCAL_ADMIN_OVERRIDE_ENABLED=true
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Login page shows the email/password form with a note that local login is restricted to administrators.
|
||||
- Super‑admins can log in with their local password; non‑super‑admins are blocked.
|
||||
- Password reset remains disabled for everyone.
|
||||
- Successful override logins are logged.
|
||||
|
||||
### 4.4 Link‑only JIT + restricted domains
|
||||
|
||||
Lock down JIT creation to specific domains and require existing users otherwise:
|
||||
|
||||
```bash
|
||||
AUTH_JIT_MODE=link_only
|
||||
ALLOWED_OIDC_DOMAINS="example.com,yourcorp.com"
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- SSO sign‑ins with emails under `example.com` or `yourcorp.com` can be linked to existing Sure users.
|
||||
- New account creation via SSO is disabled; users without accounts see appropriate messaging and must contact an admin.
|
||||
- SSO sign‑ins from any other domain cannot JIT‑create accounts.
|
||||
|
||||
---
|
||||
|
||||
With these settings, you can run Sure in:
|
||||
|
||||
- Traditional local login mode
|
||||
- Hybrid local + SSO mode
|
||||
- Strict SSO‑only mode with optional super‑admin escape hatch
|
||||
- Domain‑restricted and link‑only enterprise SSO modes
|
||||
|
||||
Use the combination that best fits your self‑hosted environment and security posture.
|
||||
|
||||
@@ -107,6 +107,50 @@ class OidcAccountsControllerTest < ActionController::TestCase
|
||||
assert_select "strong", text: new_user_auth["email"]
|
||||
end
|
||||
|
||||
test "does not show create account button when JIT link-only mode" do
|
||||
session[:pending_oidc_auth] = new_user_auth
|
||||
|
||||
AuthConfig.stubs(:jit_link_only?).returns(true)
|
||||
AuthConfig.stubs(:allowed_oidc_domain?).returns(true)
|
||||
|
||||
get :link
|
||||
assert_response :success
|
||||
|
||||
assert_select "h3", text: "Create New Account"
|
||||
# No create account button rendered
|
||||
assert_select "button", text: "Create Account", count: 0
|
||||
assert_select "p", text: /New account creation via single sign-on is disabled/
|
||||
end
|
||||
|
||||
test "create_user redirects when JIT link-only mode" do
|
||||
session[:pending_oidc_auth] = new_user_auth
|
||||
|
||||
AuthConfig.stubs(:jit_link_only?).returns(true)
|
||||
AuthConfig.stubs(:allowed_oidc_domain?).returns(true)
|
||||
|
||||
assert_no_difference [ "User.count", "OidcIdentity.count", "Family.count" ] do
|
||||
post :create_user
|
||||
end
|
||||
|
||||
assert_redirected_to new_session_path
|
||||
assert_equal "SSO account creation is disabled. Please contact an administrator.", flash[:alert]
|
||||
end
|
||||
|
||||
test "create_user redirects when email domain not allowed" do
|
||||
disallowed_auth = new_user_auth.merge("email" => "newuser@notallowed.com")
|
||||
session[:pending_oidc_auth] = disallowed_auth
|
||||
|
||||
AuthConfig.stubs(:jit_link_only?).returns(false)
|
||||
AuthConfig.stubs(:allowed_oidc_domain?).with(disallowed_auth["email"]).returns(false)
|
||||
|
||||
assert_no_difference [ "User.count", "OidcIdentity.count", "Family.count" ] do
|
||||
post :create_user
|
||||
end
|
||||
|
||||
assert_redirected_to new_session_path
|
||||
assert_equal "SSO account creation is disabled. Please contact an administrator.", flash[:alert]
|
||||
end
|
||||
|
||||
test "should create new user account via OIDC" do
|
||||
session[:pending_oidc_auth] = new_user_auth
|
||||
|
||||
|
||||
@@ -27,4 +27,25 @@ class PasswordResetsControllerTest < ActionDispatch::IntegrationTest
|
||||
params: { user: { password: "password", password_confirmation: "password" } }
|
||||
assert_redirected_to new_session_url
|
||||
end
|
||||
|
||||
test "all actions redirect when password features are disabled" do
|
||||
AuthConfig.stubs(:password_features_enabled?).returns(false)
|
||||
|
||||
get new_password_reset_path
|
||||
assert_redirected_to new_session_path
|
||||
assert_equal "Password reset via Sure is disabled. Please reset your password through your identity provider.", flash[:alert]
|
||||
|
||||
post password_reset_path, params: { email: @user.email }
|
||||
assert_redirected_to new_session_path
|
||||
assert_equal "Password reset via Sure is disabled. Please reset your password through your identity provider.", flash[:alert]
|
||||
|
||||
get edit_password_reset_path(token: @user.generate_token_for(:password_reset))
|
||||
assert_redirected_to new_session_path
|
||||
assert_equal "Password reset via Sure is disabled. Please reset your password through your identity provider.", flash[:alert]
|
||||
|
||||
patch password_reset_path(token: @user.generate_token_for(:password_reset)),
|
||||
params: { user: { password: "password", password_confirmation: "password" } }
|
||||
assert_redirected_to new_session_path
|
||||
assert_equal "Password reset via Sure is disabled. Please reset your password through your identity provider.", flash[:alert]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,6 +43,71 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal "Invalid email or password.", flash[:alert]
|
||||
end
|
||||
|
||||
test "redirects when local login is disabled" do
|
||||
AuthConfig.stubs(:local_login_enabled?).returns(false)
|
||||
AuthConfig.stubs(:local_admin_override_enabled?).returns(false)
|
||||
|
||||
post sessions_url, params: { email: @user.email, password: user_password_test }
|
||||
|
||||
assert_redirected_to new_session_path
|
||||
assert_equal "Local password login is disabled. Please use single sign-on.", flash[:alert]
|
||||
end
|
||||
|
||||
test "allows super admin local login when override enabled" do
|
||||
super_admin = users(:sure_support_staff)
|
||||
|
||||
AuthConfig.stubs(:local_login_enabled?).returns(false)
|
||||
AuthConfig.stubs(:local_admin_override_enabled?).returns(true)
|
||||
|
||||
post sessions_url, params: { email: super_admin.email, password: user_password_test }
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert Session.exists?(user_id: super_admin.id)
|
||||
end
|
||||
|
||||
test "shows invalid credentials for super admin when override enabled but password is wrong" do
|
||||
super_admin = users(:sure_support_staff)
|
||||
|
||||
AuthConfig.stubs(:local_login_enabled?).returns(false)
|
||||
AuthConfig.stubs(:local_admin_override_enabled?).returns(true)
|
||||
|
||||
post sessions_url, params: { email: super_admin.email, password: "bad" }
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal "Invalid email or password.", flash[:alert]
|
||||
end
|
||||
|
||||
test "blocks non-super-admin local login when override enabled" do
|
||||
AuthConfig.stubs(:local_login_enabled?).returns(false)
|
||||
AuthConfig.stubs(:local_admin_override_enabled?).returns(true)
|
||||
|
||||
post sessions_url, params: { email: @user.email, password: user_password_test }
|
||||
|
||||
assert_redirected_to new_session_path
|
||||
assert_equal "Local password login is disabled. Please use single sign-on.", flash[:alert]
|
||||
end
|
||||
|
||||
test "renders multiple SSO provider buttons" do
|
||||
AuthConfig.stubs(:local_login_form_visible?).returns(true)
|
||||
AuthConfig.stubs(:password_features_enabled?).returns(true)
|
||||
AuthConfig.stubs(:sso_providers).returns([
|
||||
{ id: "oidc", strategy: "openid_connect", name: "openid_connect", label: "Sign in with Keycloak", icon: "key" },
|
||||
{ id: "google", strategy: "google_oauth2", name: "google_oauth2", label: "Sign in with Google", icon: "google" }
|
||||
])
|
||||
|
||||
get new_session_path
|
||||
assert_response :success
|
||||
|
||||
# Generic OIDC button
|
||||
assert_match %r{/auth/openid_connect}, @response.body
|
||||
assert_match /Sign in with Keycloak/, @response.body
|
||||
|
||||
# Google-branded button
|
||||
assert_match %r{/auth/google_oauth2}, @response.body
|
||||
assert_match /gsi-material-button/, @response.body
|
||||
assert_match /Sign in with Google/, @response.body
|
||||
end
|
||||
|
||||
test "can sign out" do
|
||||
sign_in @user
|
||||
session_record = @user.sessions.last
|
||||
|
||||
Reference in New Issue
Block a user