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:
LPW
2025-12-23 18:15:53 -05:00
committed by GitHub
parent 8972cb59f0
commit b23711ae0d
19 changed files with 788 additions and 97 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 ||= []

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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 OIDCcompatible 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 superadmin override is allowed
- How JIT SSO account creation behaves (create vs linkonly, 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`, superadmin users can still log in with local passwords.
- Regular users remain SSOonly.
- 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 commaseparated 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 Googlebranded signin button.
- Other providers (e.g. OIDC/Keycloak, GitHub) render a generic styled button with the configured label and icon.
#### Enabling Google signin (local dev / selfhosted)
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 SSOonly
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 SSOonly with emergency admin override
Allow only superadmin 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.
- Superadmins can log in with their local password; nonsuperadmins are blocked.
- Password reset remains disabled for everyone.
- Successful override logins are logged.
### 4.4 Linkonly 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 signins 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 signins from any other domain cannot JITcreate accounts.
---
With these settings, you can run Sure in:
- Traditional local login mode
- Hybrid local + SSO mode
- Strict SSOonly mode with optional superadmin escape hatch
- Domainrestricted and linkonly enterprise SSO modes
Use the combination that best fits your selfhosted environment and security posture.

View File

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

View File

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

View File

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