Files
sure/config/initializers/omniauth.rb
Josh Waldrep 14993d871c feat: comprehensive SSO/OIDC upgrade with enterprise features
Multi-provider SSO support:
   - Database-backed SSO provider management with admin UI
   - Support for OpenID Connect, Google OAuth2, GitHub, and SAML 2.0
   - Flipper feature flag (db_sso_providers) for dynamic provider loading
   - ProviderLoader service for YAML or database configuration

   Admin functionality:
   - Admin::SsoProvidersController for CRUD operations
   - Admin::UsersController for super_admin role management
   - Pundit policies for authorization
   - Test connection endpoint for validating provider config

   User provisioning improvements:
   - JIT (just-in-time) account creation with configurable default role
   - Changed default JIT role from admin to member (security)
   - User attribute sync on each SSO login
   - Group/role mapping from IdP claims

   SSO identity management:
   - Settings::SsoIdentitiesController for users to manage connected accounts
   - Issuer validation for OIDC identities
   - Unlink protection when no password set

   Audit logging:
   - SsoAuditLog model tracking login, logout, link, unlink, JIT creation
   - Captures IP address, user agent, and metadata

   Advanced OIDC features:
   - Custom scopes per provider
   - Configurable prompt parameter (login, consent, select_account, none)
   - RP-initiated logout (federated logout to IdP)
   - id_token storage for logout

   SAML 2.0 support:
   - omniauth-saml gem integration
   - IdP metadata URL or manual configuration
   - Certificate and fingerprint validation
   - NameID format configuration
2026-01-03 17:56:42 -05:00

187 lines
6.8 KiB
Ruby

# frozen_string_literal: true
require "omniauth/rails_csrf_protection"
Rails.configuration.x.auth.oidc_enabled = false
Rails.configuration.x.auth.sso_providers ||= []
# Configure OmniAuth to handle failures gracefully
OmniAuth.config.on_failure = proc do |env|
error = env["omniauth.error"]
error_type = env["omniauth.error.type"]
strategy = env["omniauth.error.strategy"]
# Log the error for debugging
Rails.logger.error("[OmniAuth] Authentication failed: #{error_type} - #{error&.message}")
# Redirect to failure handler with error info
message = case error_type
when :discovery_failed, :invalid_credentials
"sso_provider_unavailable"
when :invalid_response
"sso_invalid_response"
else
"sso_failed"
end
Rack::Response.new([ "302 Moved" ], 302, "Location" => "/auth/failure?message=#{message}&strategy=#{strategy&.name}").finish
end
Rails.application.config.middleware.use OmniAuth::Builder do
# Load providers from either YAML or DB via ProviderLoader
providers = ProviderLoader.load_providers
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"
# Support per-provider credentials from config or fall back to global ENV vars
issuer = cfg[:issuer].presence || ENV["OIDC_ISSUER"].presence
client_id = cfg[:client_id].presence || ENV["OIDC_CLIENT_ID"].presence
client_secret = cfg[:client_secret].presence || ENV["OIDC_CLIENT_SECRET"].presence
redirect_uri = cfg[:redirect_uri].presence || ENV["OIDC_REDIRECT_URI"].presence
# In test environment, use test values if nothing is configured
if Rails.env.test?
issuer ||= "https://test.example.com"
client_id ||= "test_client_id"
client_secret ||= "test_client_secret"
redirect_uri ||= "http://test.example.com/callback"
end
# Skip if required fields are missing (except in test)
unless issuer.present? && client_id.present? && client_secret.present? && redirect_uri.present?
Rails.logger.warn("[OmniAuth] Skipping OIDC provider '#{name}' - missing required configuration")
next
end
# Custom scopes: parse from settings if provided, otherwise use defaults
custom_scopes = cfg.dig(:settings, :scopes).presence
scopes = if custom_scopes.present?
custom_scopes.to_s.split(/\s+/).map(&:to_sym)
else
%i[openid email profile]
end
# Build provider options
oidc_options = {
name: name.to_sym,
scope: scopes,
response_type: :code,
issuer: issuer.to_s.strip,
discovery: true,
pkce: true,
client_options: {
identifier: client_id,
secret: client_secret,
redirect_uri: redirect_uri
}
}
# Add prompt parameter if configured
prompt = cfg.dig(:settings, :prompt).presence
oidc_options[:prompt] = prompt if prompt.present?
provider :openid_connect, oidc_options
Rails.configuration.x.auth.oidc_enabled = true
Rails.configuration.x.auth.sso_providers << cfg.merge(name: name, issuer: issuer)
when "google_oauth2"
client_id = cfg[:client_id].presence || ENV["GOOGLE_OAUTH_CLIENT_ID"].presence
client_secret = cfg[:client_secret].presence || ENV["GOOGLE_OAUTH_CLIENT_SECRET"].presence
# Test environment fallback
if Rails.env.test?
client_id ||= "test_client_id"
client_secret ||= "test_client_secret"
end
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 = cfg[:client_id].presence || ENV["GITHUB_CLIENT_ID"].presence
client_secret = cfg[:client_secret].presence || ENV["GITHUB_CLIENT_SECRET"].presence
# Test environment fallback
if Rails.env.test?
client_id ||= "test_client_id"
client_secret ||= "test_client_secret"
end
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)
when "saml"
settings = cfg[:settings] || {}
# Require either metadata URL or manual SSO URL
idp_metadata_url = settings[:idp_metadata_url].presence || settings["idp_metadata_url"].presence
idp_sso_url = settings[:idp_sso_url].presence || settings["idp_sso_url"].presence
unless idp_metadata_url.present? || idp_sso_url.present?
Rails.logger.warn("[OmniAuth] Skipping SAML provider '#{name}' - missing IdP configuration")
next
end
# Build SAML options
saml_options = {
name: name.to_sym,
assertion_consumer_service_url: cfg[:redirect_uri].presence || "#{ENV['APP_URL']}/auth/#{name}/callback",
issuer: cfg[:issuer].presence || ENV["APP_URL"],
name_identifier_format: settings[:name_id_format].presence || settings["name_id_format"].presence ||
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
attribute_statements: {
email: [ "email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" ],
first_name: [ "first_name", "givenName", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" ],
last_name: [ "last_name", "surname", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname" ],
groups: [ "groups", "http://schemas.microsoft.com/ws/2008/06/identity/claims/groups" ]
}
}
# Use metadata URL or manual configuration
if idp_metadata_url.present?
saml_options[:idp_metadata_url] = idp_metadata_url
else
saml_options[:idp_sso_service_url] = idp_sso_url
saml_options[:idp_cert] = settings[:idp_certificate].presence || settings["idp_certificate"].presence
saml_options[:idp_cert_fingerprint] = settings[:idp_cert_fingerprint].presence || settings["idp_cert_fingerprint"].presence
end
# Optional: IdP SLO (Single Logout) URL
idp_slo_url = settings[:idp_slo_url].presence || settings["idp_slo_url"].presence
saml_options[:idp_slo_service_url] = idp_slo_url if idp_slo_url.present?
provider :saml, saml_options
Rails.configuration.x.auth.sso_providers << cfg.merge(name: name, strategy: "saml")
end
end
end
if Rails.configuration.x.auth.sso_providers.empty?
Rails.logger.warn("No SSO providers enabled; check auth.yml / ENV configuration or database providers")
end