From b23711ae0d2a76f0c4b8b2de7909c2468ffe567a Mon Sep 17 00:00:00 2001 From: LPW Date: Tue, 23 Dec 2025 18:15:53 -0500 Subject: [PATCH] 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 --- Gemfile | 4 +- Gemfile.lock | 25 ++ app/controllers/oidc_accounts_controller.rb | 20 +- app/controllers/password_resets_controller.rb | 7 + app/controllers/sessions_controller.rb | 61 ++++- app/models/auth_config.rb | 80 ++++++ app/views/oidc_accounts/link.html.erb | 22 +- app/views/sessions/new.html.erb | 98 ++++--- config/auth.yml | 55 ++++ config/initializers/auth.rb | 26 ++ config/initializers/omniauth.rb | 92 +++++-- config/locales/views/oidc_accounts/en.yml | 5 + config/locales/views/password_resets/en.yml | 1 + config/locales/views/sessions/en.yml | 3 + config/locales/views/sessions/es.yml | 3 + docs/hosting/oidc.md | 253 ++++++++++++++++-- .../oidc_accounts_controller_test.rb | 44 +++ .../password_resets_controller_test.rb | 21 ++ test/controllers/sessions_controller_test.rb | 65 +++++ 19 files changed, 788 insertions(+), 97 deletions(-) create mode 100644 app/models/auth_config.rb create mode 100644 config/auth.yml create mode 100644 config/initializers/auth.rb create mode 100644 config/locales/views/oidc_accounts/en.yml 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 %> -
-
-
- - - - - - - +<% if providers.any? %> +
+ <% 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" %> +
+ <%= button_to "/auth/#{provider_name}", method: :post, form: { data: { turbo: false } }, class: "gsi-material-button w-full" do %> +
+
+
+ + + + + + + +
+ <%= provider[:label].presence || t(".google_auth_connect") %> + <%= provider[:label].presence || t(".google_auth_connect") %> +
+ <% end %>
- <%= t(".google_auth_connect") %> - <%= t(".google_auth_connect") %> -
+ <% 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 %> + <%= provider[:label].presence || provider[:name].to_s.titleize %> + <% end %> + <% end %> <% end %>
+<% 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