Files
sure/app/models/sso_provider_tester.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

208 lines
6.8 KiB
Ruby

# frozen_string_literal: true
# Tests SSO provider configuration by validating discovery endpoints
class SsoProviderTester
extend SslConfigurable
attr_reader :provider, :result
Result = Struct.new(:success?, :message, :details, keyword_init: true)
def initialize(provider)
@provider = provider
@result = nil
end
def test!
@result = case provider.strategy
when "openid_connect"
test_oidc_discovery
when "google_oauth2"
test_google_oauth
when "github"
test_github_oauth
when "saml"
test_saml_metadata
else
Result.new(success?: false, message: "Unknown strategy: #{provider.strategy}", details: {})
end
end
private
def test_oidc_discovery
return Result.new(success?: false, message: "Issuer URL is required", details: {}) if provider.issuer.blank?
discovery_url = build_discovery_url(provider.issuer)
begin
response = faraday_client.get(discovery_url) do |req|
req.options.timeout = 10
req.options.open_timeout = 5
end
unless response.success?
return Result.new(
success?: false,
message: "Discovery endpoint returned HTTP #{response.status}",
details: { url: discovery_url, status: response.status }
)
end
discovery = JSON.parse(response.body)
# Validate required OIDC fields
required_fields = %w[issuer authorization_endpoint token_endpoint]
missing = required_fields.select { |f| discovery[f].blank? }
if missing.any?
return Result.new(
success?: false,
message: "Discovery document missing required fields: #{missing.join(", ")}",
details: { url: discovery_url, missing_fields: missing }
)
end
# Check if issuer matches
if discovery["issuer"] != provider.issuer && discovery["issuer"] != provider.issuer.chomp("/")
return Result.new(
success?: false,
message: "Issuer mismatch: expected #{provider.issuer}, got #{discovery["issuer"]}",
details: { expected: provider.issuer, actual: discovery["issuer"] }
)
end
Result.new(
success?: true,
message: "OIDC discovery validated successfully",
details: {
issuer: discovery["issuer"],
authorization_endpoint: discovery["authorization_endpoint"],
token_endpoint: discovery["token_endpoint"],
end_session_endpoint: discovery["end_session_endpoint"],
scopes_supported: discovery["scopes_supported"]
}
)
rescue Faraday::TimeoutError
Result.new(success?: false, message: "Connection timed out", details: { url: discovery_url })
rescue Faraday::ConnectionFailed => e
Result.new(success?: false, message: "Connection failed: #{e.message}", details: { url: discovery_url })
rescue JSON::ParserError
Result.new(success?: false, message: "Invalid JSON response from discovery endpoint", details: { url: discovery_url })
rescue StandardError => e
Result.new(success?: false, message: "Error: #{e.message}", details: { url: discovery_url })
end
end
def test_google_oauth
# Google OAuth doesn't require discovery validation - just check credentials present
if provider.client_id.blank?
return Result.new(success?: false, message: "Client ID is required", details: {})
end
if provider.client_secret.blank?
return Result.new(success?: false, message: "Client Secret is required", details: {})
end
Result.new(
success?: true,
message: "Google OAuth2 configuration looks valid",
details: {
note: "Full validation occurs during actual authentication"
}
)
end
def test_github_oauth
# GitHub OAuth doesn't require discovery validation - just check credentials present
if provider.client_id.blank?
return Result.new(success?: false, message: "Client ID is required", details: {})
end
if provider.client_secret.blank?
return Result.new(success?: false, message: "Client Secret is required", details: {})
end
Result.new(
success?: true,
message: "GitHub OAuth configuration looks valid",
details: {
note: "Full validation occurs during actual authentication"
}
)
end
def test_saml_metadata
# SAML testing - check for IdP metadata or SSO URL
if provider.settings&.dig("idp_metadata_url").blank? &&
provider.settings&.dig("idp_sso_url").blank?
return Result.new(
success?: false,
message: "Either IdP Metadata URL or IdP SSO URL is required",
details: {}
)
end
# If metadata URL is provided, try to fetch it
metadata_url = provider.settings&.dig("idp_metadata_url")
if metadata_url.present?
begin
response = faraday_client.get(metadata_url) do |req|
req.options.timeout = 10
req.options.open_timeout = 5
end
unless response.success?
return Result.new(
success?: false,
message: "Metadata endpoint returned HTTP #{response.status}",
details: { url: metadata_url, status: response.status }
)
end
# Basic XML validation
unless response.body.include?("<") && response.body.include?("EntityDescriptor")
return Result.new(
success?: false,
message: "Response does not appear to be valid SAML metadata",
details: { url: metadata_url }
)
end
return Result.new(
success?: true,
message: "SAML metadata fetched successfully",
details: { url: metadata_url }
)
rescue Faraday::TimeoutError
return Result.new(success?: false, message: "Connection timed out", details: { url: metadata_url })
rescue Faraday::ConnectionFailed => e
return Result.new(success?: false, message: "Connection failed: #{e.message}", details: { url: metadata_url })
rescue StandardError => e
return Result.new(success?: false, message: "Error: #{e.message}", details: { url: metadata_url })
end
end
Result.new(
success?: true,
message: "SAML configuration looks valid",
details: {
note: "Full validation occurs during actual authentication"
}
)
end
def build_discovery_url(issuer)
if issuer.end_with?("/")
"#{issuer}.well-known/openid-configuration"
else
"#{issuer}/.well-known/openid-configuration"
end
end
def faraday_client
@faraday_client ||= Faraday.new(ssl: self.class.faraday_ssl_options)
end
end