Files
sure/app/models/sso_provider.rb
BitToby ba6e286b41 feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support… (#894)
* feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support self-signed certificates in self-hosted environments

* fix: NoMethodError by defining SSL helper methods before configure block executes

* refactor: Refactor SessionsController to use shared SslConfigurable module and simplify SSL initializer redundant checks

* refactor: improve SSL configuration robustness and error detection accuracy

* fix:HTTParty SSL options, add file validation guards, prevent Tempfile GC, and redact URLs in error logs

* fix:  Fix SSL concern indentation and stub Simplefin POST correctly in tests

* fix: normalize ssl_verify to always return boolean instead of nil

* fix: solve failing SimpleFin test

* refactor:  trim unused error-handling code from SslConfigurable, replace Tempfile with fixed-path CA bundle, fix namespace pollution in initializers, and add unit tests for core SSL configuration and Langfuse CRL callback.

* fix: added require ileutils in the initializer and require ostruct in the test file.

* fix: solve autoload conflict that broke provider loading, validate all certs in PEM bundles, and add missing requires.
2026-02-06 18:04:03 +01:00

150 lines
5.2 KiB
Ruby

# frozen_string_literal: true
class SsoProvider < ApplicationRecord
include Encryptable
extend SslConfigurable
# Encrypt sensitive credentials if ActiveRecord encryption is configured
if encryption_ready?
encrypts :client_secret, deterministic: false
end
# Default enabled to true for new providers
attribute :enabled, :boolean, default: true
# Validations
validates :strategy, presence: true, inclusion: {
in: %w[openid_connect google_oauth2 github saml],
message: "%{value} is not a supported strategy"
}
validates :name, presence: true, uniqueness: true, format: {
with: /\A[a-z0-9_]+\z/,
message: "must contain only lowercase letters, numbers, and underscores"
}
validates :label, presence: true
validates :enabled, inclusion: { in: [ true, false ] }
# Strategy-specific validations
validate :validate_oidc_fields, if: -> { strategy == "openid_connect" }
validate :validate_oauth_fields, if: -> { strategy.in?(%w[google_oauth2 github]) }
validate :validate_saml_fields, if: -> { strategy == "saml" }
validate :validate_default_role_setting
# Note: OIDC discovery validation is done client-side via Stimulus
# Server-side validation can fail due to network issues, so we skip it
# validate :validate_oidc_discovery, if: -> { strategy == "openid_connect" && issuer.present? && will_save_change_to_issuer? }
# Scopes
scope :enabled, -> { where(enabled: true) }
scope :by_strategy, ->(strategy) { where(strategy: strategy) }
# Convert to hash format compatible with OmniAuth initializer
def to_omniauth_config
{
id: name,
strategy: strategy,
name: name,
label: label,
icon: icon,
issuer: issuer,
client_id: client_id,
client_secret: client_secret,
redirect_uri: redirect_uri,
settings: settings || {}
}.compact
end
private
def validate_oidc_fields
if issuer.blank?
errors.add(:issuer, "is required for OpenID Connect providers")
elsif issuer.present? && !valid_url?(issuer)
errors.add(:issuer, "must be a valid URL")
end
errors.add(:client_id, "is required for OpenID Connect providers") if client_id.blank?
errors.add(:client_secret, "is required for OpenID Connect providers") if client_secret.blank?
if redirect_uri.present? && !valid_url?(redirect_uri)
errors.add(:redirect_uri, "must be a valid URL")
end
end
def validate_oauth_fields
errors.add(:client_id, "is required for OAuth providers") if client_id.blank?
errors.add(:client_secret, "is required for OAuth providers") if client_secret.blank?
end
def validate_saml_fields
# SAML requires either a metadata URL or manual configuration
idp_metadata_url = settings&.dig("idp_metadata_url")
idp_sso_url = settings&.dig("idp_sso_url")
if idp_metadata_url.blank? && idp_sso_url.blank?
errors.add(:settings, "Either IdP Metadata URL or IdP SSO URL is required for SAML providers")
end
# If using manual config, require certificate
if idp_metadata_url.blank? && idp_sso_url.present?
idp_cert = settings&.dig("idp_certificate")
idp_fingerprint = settings&.dig("idp_cert_fingerprint")
if idp_cert.blank? && idp_fingerprint.blank?
errors.add(:settings, "Either IdP Certificate or Certificate Fingerprint is required when not using metadata URL")
end
end
# Validate URL formats if provided
if idp_metadata_url.present? && !valid_url?(idp_metadata_url)
errors.add(:settings, "IdP Metadata URL must be a valid URL")
end
if idp_sso_url.present? && !valid_url?(idp_sso_url)
errors.add(:settings, "IdP SSO URL must be a valid URL")
end
end
def validate_default_role_setting
default_role = settings&.dig("default_role")
return if default_role.blank?
unless User.roles.key?(default_role)
errors.add(:settings, "default_role must be member, admin, or super_admin")
end
end
def validate_oidc_discovery
return unless issuer.present?
begin
discovery_url = issuer.end_with?("/") ? "#{issuer}.well-known/openid-configuration" : "#{issuer}/.well-known/openid-configuration"
response = Faraday.new(ssl: self.class.faraday_ssl_options).get(discovery_url) do |req|
req.options.timeout = 5
req.options.open_timeout = 3
end
unless response.success?
errors.add(:issuer, "discovery endpoint returned #{response.status}")
return
end
discovery_data = JSON.parse(response.body)
unless discovery_data["issuer"].present?
errors.add(:issuer, "discovery endpoint did not return valid issuer")
end
rescue Faraday::Error => e
errors.add(:issuer, "could not connect to discovery endpoint: #{e.message}")
rescue JSON::ParserError
errors.add(:issuer, "discovery endpoint returned invalid JSON")
rescue StandardError => e
errors.add(:issuer, "discovery validation failed: #{e.message}")
end
end
def valid_url?(url)
uri = URI.parse(url)
uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
rescue URI::InvalidURIError
false
end
end