diff --git a/Gemfile b/Gemfile
index 3e6809a32..91cdfcd8b 100644
--- a/Gemfile
+++ b/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"
diff --git a/Gemfile.lock b/Gemfile.lock
index d26fb9152..aaebedec1 100644
--- a/Gemfile.lock
+++ b/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
diff --git a/app/controllers/oidc_accounts_controller.rb b/app/controllers/oidc_accounts_controller.rb
index b6d7466bd..a89dc9395 100644
--- a/app/controllers/oidc_accounts_controller.rb
+++ b/app/controllers/oidc_accounts_controller.rb
@@ -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
diff --git a/app/controllers/password_resets_controller.rb b/app/controllers/password_resets_controller.rb
index 41e5eecd6..76d0adb1a 100644
--- a/app/controllers/password_resets_controller.rb
+++ b/app/controllers/password_resets_controller.rb
@@ -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?
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index c9904ea6b..f42fac451 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -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
diff --git a/app/models/auth_config.rb b/app/models/auth_config.rb
new file mode 100644
index 000000000..7cb630966
--- /dev/null
+++ b/app/models/auth_config.rb
@@ -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
diff --git a/app/views/oidc_accounts/link.html.erb b/app/views/oidc_accounts/link.html.erb
index dbed9f2cd..ae75e6d8e 100644
--- a/app/views/oidc_accounts/link.html.erb
+++ b/app/views/oidc_accounts/link.html.erb
@@ -53,14 +53,20 @@
<% end %>
- <%= 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 %>
+
+ <%= t("oidc_accounts.link.account_creation_disabled") %>
+
+ <% end %>
<% end %>
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
index 72afcaf49..ce92c5e54 100644
--- a/app/views/sessions/new.html.erb
+++ b/app/views/sessions/new.html.erb
@@ -16,45 +16,77 @@
<% 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? %>
+
+ <%= t(".local_login_admin_only") %>
+
+ <% end %>
+
+ <% if AuthConfig.password_features_enabled? %>
+
+ <%= link_to t(".forgot_password"), new_password_reset_path, class: "font-medium text-sm text-primary hover:underline transition" %>
+
+ <% end %>
<% end %>
-
- <%= link_to t(".forgot_password"), new_password_reset_path, class: "font-medium text-sm text-primary hover:underline transition" %>
-
+<% providers = AuthConfig.sso_providers %>
-<% if Rails.configuration.x.auth.oidc_enabled %>
-
- <%= button_to "/auth/openid_connect", method: :post, form: { data: { turbo: false } }, class: "gsi-material-button" do %>
-
-
-
+<% elsif !AuthConfig.local_login_form_visible? %>
+
+ <%= t(".no_auth_methods_enabled") %>
+
<% end %>
diff --git a/config/auth.yml b/config/auth.yml
new file mode 100644
index 000000000..1e237cca2
--- /dev/null
+++ b/config/auth.yml
@@ -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
diff --git a/config/initializers/auth.rb b/config/initializers/auth.rb
new file mode 100644
index 000000000..c77999e49
--- /dev/null
+++ b/config/initializers/auth.rb
@@ -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 ||= []
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
index 4fbe184c3..9b836e436 100644
--- a/config/initializers/omniauth.rb
+++ b/config/initializers/omniauth.rb
@@ -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
diff --git a/config/locales/views/oidc_accounts/en.yml b/config/locales/views/oidc_accounts/en.yml
new file mode 100644
index 000000000..d39572b2a
--- /dev/null
+++ b/config/locales/views/oidc_accounts/en.yml
@@ -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.
diff --git a/config/locales/views/password_resets/en.yml b/config/locales/views/password_resets/en.yml
index 8074bd374..89b152b49 100644
--- a/config/locales/views/password_resets/en.yml
+++ b/config/locales/views/password_resets/en.yml
@@ -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:
diff --git a/config/locales/views/sessions/en.yml b/config/locales/views/sessions/en.yml
index 88afdafec..8891e194d 100644
--- a/config/locales/views/sessions/en.yml
+++ b/config/locales/views/sessions/en.yml
@@ -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."
diff --git a/config/locales/views/sessions/es.yml b/config/locales/views/sessions/es.yml
index fc750b1b2..5eafcf9d7 100644
--- a/config/locales/views/sessions/es.yml
+++ b/config/locales/views/sessions/es.yml
@@ -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.
diff --git a/docs/hosting/oidc.md b/docs/hosting/oidc.md
index 473b23693..1db92e8ed 100644
--- a/docs/hosting/oidc.md
+++ b/docs/hosting/oidc.md
@@ -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
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//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.
diff --git a/test/controllers/oidc_accounts_controller_test.rb b/test/controllers/oidc_accounts_controller_test.rb
index 6e6238bd0..dab141d1d 100644
--- a/test/controllers/oidc_accounts_controller_test.rb
+++ b/test/controllers/oidc_accounts_controller_test.rb
@@ -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
diff --git a/test/controllers/password_resets_controller_test.rb b/test/controllers/password_resets_controller_test.rb
index aa2d5521d..42417c1c9 100644
--- a/test/controllers/password_resets_controller_test.rb
+++ b/test/controllers/password_resets_controller_test.rb
@@ -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
diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb
index 1014136c1..3f2da7351 100644
--- a/test/controllers/sessions_controller_test.rb
+++ b/test/controllers/sessions_controller_test.rb
@@ -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