mirror of
https://github.com/we-promise/sure.git
synced 2026-04-12 16:47:22 +00:00
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
145 lines
5.1 KiB
Ruby
145 lines
5.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class SsoProvider < ApplicationRecord
|
|
# Encrypt sensitive credentials using Rails 7.2 built-in encryption
|
|
encrypts :client_secret, deterministic: false
|
|
|
|
# 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.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
|