better_auth-saml 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/README.md +34 -0
- data/lib/better_auth/plugins/saml.rb +5 -0
- data/lib/better_auth/saml/version.rb +7 -0
- data/lib/better_auth/saml.rb +16 -0
- data/lib/better_auth/sso/constants.rb +20 -0
- data/lib/better_auth/sso/plugin/saml_core.rb +54 -0
- data/lib/better_auth/sso/plugin/saml_metadata_and_logout.rb +378 -0
- data/lib/better_auth/sso/plugin/saml_response.rb +167 -0
- data/lib/better_auth/sso/plugin/saml_validation_and_state.rb +183 -0
- data/lib/better_auth/sso/routes/saml_pipeline.rb +19 -0
- data/lib/better_auth/sso/saml/algorithms.rb +96 -0
- data/lib/better_auth/sso/saml/assertions.rb +21 -0
- data/lib/better_auth/sso/saml/error_codes.rb +24 -0
- data/lib/better_auth/sso/saml/parser.rb +19 -0
- data/lib/better_auth/sso/saml/timestamp.rb +19 -0
- data/lib/better_auth/sso/saml.rb +173 -0
- data/lib/better_auth/sso/saml_hooks.rb +21 -0
- data/lib/better_auth/sso/saml_state.rb +30 -0
- metadata +195 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b0c6fdff13ef1932c9595bb990cb734c56b7f9566593639f49af0ca365ace374
|
|
4
|
+
data.tar.gz: 3e2f4b7fe11407f40f9f94358c7c015983a8e9589d6599aaccc96909ce901ddc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5b187bc5a6f1344b241f2ede52b0b12a047a15614ee685992d15f1984705b691934950929433bfcacacdbbad45833f0807cc9e5ecba7d091d3ab22ee45db3ac6
|
|
7
|
+
data.tar.gz: 0740e12b20e088ebe0d301becdbb8ba593e32a1e28e9fadb62ec94d4212ec10dac4f143183755929eabf44b6342f3bf24c7fef5820ad0f85363e1f9007e73489
|
data/CHANGELOG.md
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Better Auth SAML
|
|
2
|
+
|
|
3
|
+
SAML 2.0 service provider primitives for Better Auth Ruby enterprise SSO.
|
|
4
|
+
|
|
5
|
+
This package owns `ruby-saml` and SAML-specific plugin extensions. OIDC-only deployments should not install it.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "better_auth"
|
|
9
|
+
require "better_auth/saml"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
For the full SSO plugin, pair with `better_auth-sso` (which depends on `better_auth-oidc`):
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem "better_auth-sso"
|
|
16
|
+
gem "better_auth-saml"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
require "better_auth/sso"
|
|
21
|
+
|
|
22
|
+
BetterAuth.auth(
|
|
23
|
+
plugins: [
|
|
24
|
+
BetterAuth::Plugins.sso(
|
|
25
|
+
BetterAuth::SSO::SAMLHooks.merge_options(
|
|
26
|
+
{},
|
|
27
|
+
BetterAuth::SSO::SAML.sso_options
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
]
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
SAML is a protocol used by SSO; it is not the same feature as SSO itself. See `better_auth-sso` for provider management and composed routes.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "better_auth"
|
|
4
|
+
require_relative "saml/version"
|
|
5
|
+
require_relative "sso/plugin/saml_core"
|
|
6
|
+
require_relative "sso/saml"
|
|
7
|
+
require_relative "sso/saml/algorithms"
|
|
8
|
+
require_relative "sso/saml/assertions"
|
|
9
|
+
require_relative "sso/saml/error_codes"
|
|
10
|
+
require_relative "sso/saml/timestamp"
|
|
11
|
+
require_relative "sso/saml/parser"
|
|
12
|
+
require_relative "sso/saml_hooks"
|
|
13
|
+
require_relative "sso/saml_state"
|
|
14
|
+
require_relative "sso/routes/saml_pipeline"
|
|
15
|
+
require_relative "plugins/saml"
|
|
16
|
+
require_relative "sso/constants"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module SSO
|
|
5
|
+
module Constants
|
|
6
|
+
AUTHN_REQUEST_KEY_PREFIX = BetterAuth::Plugins::SSO_SAML_AUTHN_REQUEST_KEY_PREFIX
|
|
7
|
+
USED_ASSERTION_KEY_PREFIX = BetterAuth::Plugins::SSO_SAML_USED_ASSERTION_KEY_PREFIX
|
|
8
|
+
SAML_SESSION_KEY_PREFIX = BetterAuth::Plugins::SSO_SAML_SESSION_KEY_PREFIX
|
|
9
|
+
SAML_SESSION_BY_ID_PREFIX = BetterAuth::Plugins::SSO_SAML_SESSION_BY_ID_KEY_PREFIX
|
|
10
|
+
LOGOUT_REQUEST_KEY_PREFIX = BetterAuth::Plugins::SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX
|
|
11
|
+
DEFAULT_AUTHN_REQUEST_TTL_MS = BetterAuth::Plugins::SSO_DEFAULT_AUTHN_REQUEST_TTL_MS
|
|
12
|
+
DEFAULT_ASSERTION_TTL_MS = BetterAuth::Plugins::SSO_DEFAULT_ASSERTION_TTL_MS
|
|
13
|
+
DEFAULT_LOGOUT_REQUEST_TTL_MS = BetterAuth::Plugins::SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS
|
|
14
|
+
DEFAULT_CLOCK_SKEW_MS = BetterAuth::Plugins::SSO_DEFAULT_CLOCK_SKEW_MS
|
|
15
|
+
DEFAULT_MAX_SAML_RESPONSE_SIZE = BetterAuth::Plugins::SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE
|
|
16
|
+
DEFAULT_MAX_SAML_METADATA_SIZE = BetterAuth::Plugins::SSO_DEFAULT_MAX_SAML_METADATA_SIZE
|
|
17
|
+
SAML_STATUS_SUCCESS = BetterAuth::Plugins::SSO_SAML_STATUS_SUCCESS
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
SSO_SAML_SIGNATURE_ALGORITHMS = {
|
|
6
|
+
"rsa-sha1" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
7
|
+
"rsa-sha256" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
8
|
+
"rsa-sha384" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
9
|
+
"rsa-sha512" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
10
|
+
"ecdsa-sha256" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
11
|
+
"ecdsa-sha384" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
12
|
+
"ecdsa-sha512" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512",
|
|
13
|
+
"sha1" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
14
|
+
"sha256" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
15
|
+
"sha384" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
16
|
+
"sha512" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
SSO_SAML_DIGEST_ALGORITHMS = {
|
|
20
|
+
"sha1" => "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
21
|
+
"sha256" => "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
22
|
+
"sha384" => "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
23
|
+
"sha512" => "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
SSO_SAML_SECURE_SIGNATURE_ALGORITHMS = (SSO_SAML_SIGNATURE_ALGORITHMS.values - ["http://www.w3.org/2000/09/xmldsig#rsa-sha1"]).uniq.freeze
|
|
27
|
+
SSO_SAML_SECURE_DIGEST_ALGORITHMS = (SSO_SAML_DIGEST_ALGORITHMS.values - ["http://www.w3.org/2000/09/xmldsig#sha1"]).uniq.freeze
|
|
28
|
+
SSO_SAML_SECURE_KEY_ENCRYPTION_ALGORITHMS = %w[
|
|
29
|
+
http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p
|
|
30
|
+
http://www.w3.org/2009/xmlenc11#rsa-oaep
|
|
31
|
+
].freeze
|
|
32
|
+
SSO_SAML_SECURE_DATA_ENCRYPTION_ALGORITHMS = %w[
|
|
33
|
+
http://www.w3.org/2001/04/xmlenc#aes128-cbc
|
|
34
|
+
http://www.w3.org/2001/04/xmlenc#aes192-cbc
|
|
35
|
+
http://www.w3.org/2001/04/xmlenc#aes256-cbc
|
|
36
|
+
http://www.w3.org/2009/xmlenc11#aes128-gcm
|
|
37
|
+
http://www.w3.org/2009/xmlenc11#aes192-gcm
|
|
38
|
+
http://www.w3.org/2009/xmlenc11#aes256-gcm
|
|
39
|
+
].freeze
|
|
40
|
+
SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024
|
|
41
|
+
SSO_DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024
|
|
42
|
+
SSO_SAML_RELAY_STATE_KEY_PREFIX = "saml-relay-state:"
|
|
43
|
+
SSO_SAML_AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:"
|
|
44
|
+
SSO_DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000
|
|
45
|
+
SSO_SAML_USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:"
|
|
46
|
+
SSO_DEFAULT_ASSERTION_TTL_MS = 15 * 60 * 1000
|
|
47
|
+
SSO_DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000
|
|
48
|
+
SSO_SAML_SESSION_KEY_PREFIX = "saml-session:"
|
|
49
|
+
SSO_SAML_SESSION_BY_ID_KEY_PREFIX = "saml-session-by-id:"
|
|
50
|
+
SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX = "saml-logout-request:"
|
|
51
|
+
SSO_SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
|
|
52
|
+
SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS = 5 * 60 * 1000
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def sso_validate_saml_config!(saml_config, plugin_config = {})
|
|
8
|
+
metadata = saml_config[:idp_metadata] || saml_config[:metadata] || saml_config[:idp_metadata_xml]
|
|
9
|
+
idp_metadata = normalize_hash(saml_config[:idp_metadata] || {})
|
|
10
|
+
has_idp_metadata_xml = !idp_metadata[:metadata].to_s.empty? || !saml_config[:metadata].to_s.empty? || !saml_config[:idp_metadata_xml].to_s.empty?
|
|
11
|
+
has_idp_sso_service = !Array(idp_metadata[:single_sign_on_service] || saml_config[:single_sign_on_service]).empty?
|
|
12
|
+
max_metadata_size = plugin_config.dig(:saml, :max_metadata_size) || SSO_DEFAULT_MAX_SAML_METADATA_SIZE
|
|
13
|
+
if metadata.to_s.bytesize > max_metadata_size
|
|
14
|
+
raise APIError.new("BAD_REQUEST", message: "IdP metadata exceeds maximum allowed size (#{max_metadata_size} bytes)")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
if saml_config[:entry_point].to_s.empty? && !has_idp_sso_service && !has_idp_metadata_xml
|
|
18
|
+
raise APIError.new("BAD_REQUEST", message: "SAML configuration requires either idpMetadata.metadata, idpMetadata.singleSignOnService, or a valid entryPoint URL")
|
|
19
|
+
end
|
|
20
|
+
sso_validate_url!(saml_config[:entry_point], "SAML entryPoint must be a valid URL") unless saml_config[:entry_point].to_s.empty?
|
|
21
|
+
unless saml_config[:single_sign_on_service].to_s.empty?
|
|
22
|
+
sso_validate_url!(saml_config[:single_sign_on_service], "SAML singleSignOnService must be a valid URL")
|
|
23
|
+
end
|
|
24
|
+
unless saml_config[:single_logout_service].to_s.empty?
|
|
25
|
+
sso_validate_url!(saml_config[:single_logout_service], "SAML singleLogoutService must be a valid URL")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
config_algorithm_xml = +""
|
|
29
|
+
unless saml_config[:signature_algorithm].to_s.empty?
|
|
30
|
+
config_algorithm_xml << "<ds:SignatureMethod Algorithm=\"#{saml_config[:signature_algorithm]}\"/>"
|
|
31
|
+
end
|
|
32
|
+
unless saml_config[:digest_algorithm].to_s.empty?
|
|
33
|
+
config_algorithm_xml << "<ds:DigestMethod Algorithm=\"#{saml_config[:digest_algorithm]}\"/>"
|
|
34
|
+
end
|
|
35
|
+
sso_validate_saml_algorithms!(
|
|
36
|
+
config_algorithm_xml,
|
|
37
|
+
on_deprecated: plugin_config.dig(:saml, :algorithms, :on_deprecated) || saml_config[:on_deprecated_algorithm] || "warn",
|
|
38
|
+
allowed_signature_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_signature_algorithms) || saml_config[:allowed_signature_algorithms],
|
|
39
|
+
allowed_digest_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_digest_algorithms) || saml_config[:allowed_digest_algorithms],
|
|
40
|
+
allowed_key_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_key_encryption_algorithms) || saml_config[:allowed_key_encryption_algorithms],
|
|
41
|
+
allowed_data_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_data_encryption_algorithms) || saml_config[:allowed_data_encryption_algorithms]
|
|
42
|
+
)
|
|
43
|
+
sso_validate_saml_algorithms!(
|
|
44
|
+
metadata.to_s,
|
|
45
|
+
on_deprecated: plugin_config.dig(:saml, :algorithms, :on_deprecated) || saml_config[:on_deprecated_algorithm] || "warn",
|
|
46
|
+
allowed_signature_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_signature_algorithms) || saml_config[:allowed_signature_algorithms],
|
|
47
|
+
allowed_digest_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_digest_algorithms) || saml_config[:allowed_digest_algorithms],
|
|
48
|
+
allowed_key_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_key_encryption_algorithms) || saml_config[:allowed_key_encryption_algorithms],
|
|
49
|
+
allowed_data_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_data_encryption_algorithms) || saml_config[:allowed_data_encryption_algorithms]
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def sso_sp_metadata_xml(ctx, provider, config = {})
|
|
54
|
+
provider_id = provider.fetch("providerId")
|
|
55
|
+
saml_config = sso_provider_config_hash(provider["samlConfig"])
|
|
56
|
+
explicit_metadata = saml_config.dig(:sp_metadata, :metadata)
|
|
57
|
+
return explicit_metadata unless explicit_metadata.to_s.empty?
|
|
58
|
+
|
|
59
|
+
entity_id = saml_config.dig(:sp_metadata, :entity_id) || saml_config[:audience] || provider["issuer"] || "#{ctx.context.base_url}/sso/saml2/sp/metadata?providerId=#{URI.encode_www_form_component(provider_id)}"
|
|
60
|
+
acs_url = sso_saml_acs_url(ctx, provider)
|
|
61
|
+
authn_requests_signed = !!saml_config[:authn_requests_signed]
|
|
62
|
+
want_assertions_signed = saml_config.key?(:want_assertions_signed) ? !!saml_config[:want_assertions_signed] : true
|
|
63
|
+
escaped_entity_id = CGI.escapeHTML(entity_id.to_s)
|
|
64
|
+
escaped_acs_url = CGI.escapeHTML(acs_url.to_s)
|
|
65
|
+
name_id_format = saml_config[:identifier_format].to_s.empty? ? "" : "<NameIDFormat>#{CGI.escapeHTML(saml_config[:identifier_format].to_s)}</NameIDFormat>"
|
|
66
|
+
slo = if config.dig(:saml, :enable_single_logout)
|
|
67
|
+
location = CGI.escapeHTML("#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}")
|
|
68
|
+
"<SingleLogoutService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Location=\"#{location}\" /><SingleLogoutService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" Location=\"#{location}\" />"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
"<EntityDescriptor entityID=\"#{escaped_entity_id}\"><SPSSODescriptor AuthnRequestsSigned=\"#{authn_requests_signed}\" WantAssertionsSigned=\"#{want_assertions_signed}\">#{slo}#{name_id_format}<AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Location=\"#{escaped_acs_url}\" index=\"0\" /></SPSSODescriptor></EntityDescriptor>"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sso_saml_acs_url(ctx, provider)
|
|
75
|
+
provider_id = provider.fetch("providerId")
|
|
76
|
+
base_url = ctx.context.base_url
|
|
77
|
+
configured = sso_provider_config_hash(provider["samlConfig"])[:callback_url].to_s
|
|
78
|
+
return configured if sso_saml_acs_url?(configured)
|
|
79
|
+
|
|
80
|
+
"#{base_url}/sso/saml2/sp/acs/#{URI.encode_www_form_component(provider_id)}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def sso_saml_acs_url?(url)
|
|
84
|
+
return false if url.to_s.empty?
|
|
85
|
+
|
|
86
|
+
URI.parse(url.to_s).path.include?("/sso/saml2/sp/acs")
|
|
87
|
+
rescue
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def sso_saml_idp_metadata(provider_or_config)
|
|
92
|
+
saml_config = if provider_or_config.respond_to?(:key?) && (provider_or_config.key?("samlConfig") || provider_or_config.key?(:samlConfig))
|
|
93
|
+
normalize_hash(provider_or_config["samlConfig"] || provider_or_config[:samlConfig] || {})
|
|
94
|
+
else
|
|
95
|
+
normalize_hash(provider_or_config || {})
|
|
96
|
+
end
|
|
97
|
+
idp_metadata = normalize_hash(saml_config[:idp_metadata] || {})
|
|
98
|
+
xml = idp_metadata[:metadata] || saml_config[:metadata] || saml_config[:idp_metadata_xml]
|
|
99
|
+
parsed = xml.to_s.strip.empty? ? {} : sso_parse_saml_metadata_xml(xml)
|
|
100
|
+
parsed[:entity_id] ||= idp_metadata[:entity_id] || idp_metadata[:entityID] || saml_config[:issuer]
|
|
101
|
+
parsed[:cert] ||= idp_metadata[:cert] || saml_config[:cert]
|
|
102
|
+
parsed[:single_sign_on_service] = sso_saml_metadata_services_from_config(idp_metadata[:single_sign_on_service] || saml_config[:single_sign_on_service]) if parsed[:single_sign_on_service].to_a.empty?
|
|
103
|
+
parsed[:single_logout_service] = sso_saml_metadata_services_from_config(idp_metadata[:single_logout_service] || saml_config[:single_logout_service]) if parsed[:single_logout_service].to_a.empty?
|
|
104
|
+
parsed
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def sso_parse_saml_metadata_xml(xml)
|
|
108
|
+
doc = REXML::Document.new(xml.to_s)
|
|
109
|
+
root = doc.root
|
|
110
|
+
{
|
|
111
|
+
entity_id: root&.attributes&.[]("entityID"),
|
|
112
|
+
cert: sso_saml_normalize_certificate(sso_saml_metadata_first_text(doc, "X509Certificate")),
|
|
113
|
+
single_sign_on_service: sso_saml_metadata_services(doc, "SingleSignOnService"),
|
|
114
|
+
single_logout_service: sso_saml_metadata_services(doc, "SingleLogoutService")
|
|
115
|
+
}.compact
|
|
116
|
+
rescue
|
|
117
|
+
{}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def sso_saml_metadata_services(doc, element_name)
|
|
121
|
+
services = []
|
|
122
|
+
REXML::XPath.each(doc, "//*") do |element|
|
|
123
|
+
next unless element.name == element_name
|
|
124
|
+
|
|
125
|
+
services << {
|
|
126
|
+
binding: element.attributes["Binding"],
|
|
127
|
+
location: element.attributes["Location"]
|
|
128
|
+
}.compact
|
|
129
|
+
end
|
|
130
|
+
services
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def sso_saml_metadata_first_text(doc, element_name)
|
|
134
|
+
REXML::XPath.each(doc, "//*") do |element|
|
|
135
|
+
return element.text.to_s.strip if element.name == element_name && !element.text.to_s.strip.empty?
|
|
136
|
+
end
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def sso_saml_metadata_services_from_config(value)
|
|
141
|
+
Array(value).filter_map do |entry|
|
|
142
|
+
data = normalize_hash(entry || {})
|
|
143
|
+
next if data[:location].to_s.empty?
|
|
144
|
+
|
|
145
|
+
{binding: data[:binding] || data[:Binding], location: data[:location] || data[:Location]}.compact
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def sso_saml_preferred_service(services)
|
|
150
|
+
Array(services).find { |service| normalize_hash(service)[:binding].to_s.include?("HTTP-Redirect") } || Array(services).first
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def sso_saml_normalize_certificate(value)
|
|
154
|
+
cert = value.to_s.strip
|
|
155
|
+
return nil if cert.empty?
|
|
156
|
+
return cert if cert.include?("BEGIN CERTIFICATE")
|
|
157
|
+
|
|
158
|
+
"-----BEGIN CERTIFICATE-----\n#{cert.scan(/.{1,64}/).join("\n")}\n-----END CERTIFICATE-----"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def sso_saml_callback_url(provider)
|
|
162
|
+
saml_config = sso_provider_config_hash(provider["samlConfig"])
|
|
163
|
+
saml_config[:callback_url]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def sso_saml_logout_destination(provider)
|
|
167
|
+
saml_config = sso_provider_config_hash(provider["samlConfig"])
|
|
168
|
+
direct = saml_config[:single_logout_service] ||
|
|
169
|
+
saml_config[:single_logout_service_url] ||
|
|
170
|
+
saml_config[:idp_slo_service_url] ||
|
|
171
|
+
saml_config[:logout_url]
|
|
172
|
+
return direct unless direct.to_s.empty?
|
|
173
|
+
|
|
174
|
+
service = sso_saml_preferred_service(sso_saml_idp_metadata(saml_config)[:single_logout_service])
|
|
175
|
+
normalize_hash(service || {})[:location]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def sso_store_saml_session(ctx, provider, assertion, session)
|
|
179
|
+
name_id = assertion[:name_id] || assertion[:nameid] || assertion[:email]
|
|
180
|
+
session_index = assertion[:session_index] || assertion[:sessionindex] || assertion[:id]
|
|
181
|
+
return if name_id.to_s.empty? || session_index.to_s.empty?
|
|
182
|
+
|
|
183
|
+
record = {
|
|
184
|
+
providerId: provider.fetch("providerId"),
|
|
185
|
+
sessionToken: session.fetch("token"),
|
|
186
|
+
userId: session.fetch("userId"),
|
|
187
|
+
nameId: name_id.to_s,
|
|
188
|
+
sessionIndex: session_index.to_s
|
|
189
|
+
}
|
|
190
|
+
expires_at = session["expiresAt"] || Time.now + (SSO_DEFAULT_ASSERTION_TTL_MS / 1000.0)
|
|
191
|
+
value = JSON.generate(record)
|
|
192
|
+
session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{name_id}"
|
|
193
|
+
ctx.context.internal_adapter.create_verification_value(
|
|
194
|
+
identifier: session_identifier,
|
|
195
|
+
value: value,
|
|
196
|
+
expiresAt: expires_at
|
|
197
|
+
)
|
|
198
|
+
ctx.context.internal_adapter.create_verification_value(
|
|
199
|
+
identifier: "#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session.fetch("token")}",
|
|
200
|
+
value: session_identifier,
|
|
201
|
+
expiresAt: expires_at
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def sso_process_saml_logout_request(ctx, provider, raw_request)
|
|
206
|
+
data = sso_parse_saml_logout_request(raw_request)
|
|
207
|
+
return data if data[:name_id].to_s.empty?
|
|
208
|
+
|
|
209
|
+
session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{data[:name_id]}"
|
|
210
|
+
verification = ctx.context.internal_adapter.find_verification_value(session_identifier)
|
|
211
|
+
return data unless verification
|
|
212
|
+
|
|
213
|
+
record = JSON.parse(verification.fetch("value"))
|
|
214
|
+
session_token = record["sessionToken"]
|
|
215
|
+
session_index_matches = data[:session_index].to_s.empty? || record["sessionIndex"].to_s.empty? || data[:session_index].to_s == record["sessionIndex"].to_s
|
|
216
|
+
ctx.context.internal_adapter.delete_session(session_token) if session_token && session_index_matches
|
|
217
|
+
ctx.context.internal_adapter.delete_verification_by_identifier(session_identifier)
|
|
218
|
+
ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}") if session_token
|
|
219
|
+
data
|
|
220
|
+
rescue
|
|
221
|
+
{}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def sso_store_saml_logout_request(ctx, provider, request_id, config)
|
|
225
|
+
ttl_ms = (config.dig(:saml, :logout_request_ttl) || SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS).to_i
|
|
226
|
+
ctx.context.internal_adapter.create_verification_value(
|
|
227
|
+
identifier: "#{SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX}#{request_id}",
|
|
228
|
+
value: provider.fetch("providerId"),
|
|
229
|
+
expiresAt: Time.now + (ttl_ms / 1000.0)
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def sso_process_saml_logout_response(ctx, raw_response)
|
|
234
|
+
data = sso_parse_saml_logout_response(raw_response)
|
|
235
|
+
status_code = data[:status_code]
|
|
236
|
+
if status_code && status_code != SSO_SAML_STATUS_SUCCESS
|
|
237
|
+
raise APIError.new("BAD_REQUEST", message: "Logout failed at IdP")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
in_response_to = data[:in_response_to]
|
|
241
|
+
return if in_response_to.to_s.empty?
|
|
242
|
+
|
|
243
|
+
ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX}#{in_response_to}")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def sso_parse_saml_logout_request(raw_request)
|
|
247
|
+
xml = Base64.decode64(raw_request.to_s.gsub(/\s+/, ""))
|
|
248
|
+
{
|
|
249
|
+
id: xml[/\bID=['"]([^'"]+)['"]/, 1],
|
|
250
|
+
name_id: xml[%r{<(?:\w+:)?NameID[^>]*>([^<]+)</(?:\w+:)?NameID>}, 1],
|
|
251
|
+
session_index: xml[%r{<(?:\w+:)?SessionIndex[^>]*>([^<]+)</(?:\w+:)?SessionIndex>}, 1]
|
|
252
|
+
}
|
|
253
|
+
rescue
|
|
254
|
+
{}
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def sso_validate_saml_slo_signature!(ctx, raw_message, error_message)
|
|
258
|
+
signature = sso_fetch(ctx.body, :signature) || sso_fetch(ctx.query, :signature)
|
|
259
|
+
sig_alg = sso_fetch(ctx.body, :sig_alg) || sso_fetch(ctx.query, :sig_alg)
|
|
260
|
+
if !signature.to_s.empty? && !sig_alg.to_s.empty?
|
|
261
|
+
return true if sso_validate_saml_redirect_signature(ctx, raw_message, signature, sig_alg)
|
|
262
|
+
|
|
263
|
+
raise APIError.new("BAD_REQUEST", message: error_message)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
xml = Base64.decode64(raw_message.to_s.gsub(/\s+/, ""))
|
|
267
|
+
return true if xml.include?("<Signature") || xml.include?(":Signature")
|
|
268
|
+
|
|
269
|
+
raise APIError.new("BAD_REQUEST", message: error_message)
|
|
270
|
+
rescue APIError
|
|
271
|
+
raise
|
|
272
|
+
rescue
|
|
273
|
+
raise APIError.new("BAD_REQUEST", message: error_message)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def sso_validate_saml_redirect_signature(ctx, raw_message, signature, sig_alg)
|
|
277
|
+
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
278
|
+
cert = sso_saml_idp_metadata(provider)[:cert]
|
|
279
|
+
certificate = OpenSSL::X509::Certificate.new(cert.to_s)
|
|
280
|
+
has_saml_request = sso_fetch(ctx.body, :saml_request) || sso_fetch(ctx.query, :saml_request)
|
|
281
|
+
saml_param = has_saml_request ? "SAMLRequest" : "SAMLResponse"
|
|
282
|
+
relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
|
|
283
|
+
payload = [[saml_param, raw_message]]
|
|
284
|
+
payload << ["RelayState", relay_state] unless relay_state.to_s.empty?
|
|
285
|
+
payload << ["SigAlg", sig_alg]
|
|
286
|
+
certificate.public_key.verify(sso_saml_signature_digest(sig_alg), Base64.decode64(signature.to_s), URI.encode_www_form(payload))
|
|
287
|
+
rescue
|
|
288
|
+
false
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def sso_parse_saml_logout_response(raw_response)
|
|
292
|
+
xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
|
|
293
|
+
{
|
|
294
|
+
in_response_to: xml[/\bInResponseTo=['"]([^'"]+)['"]/, 1],
|
|
295
|
+
status_code: xml[/<(?:\w+:)?StatusCode\b[^>]*\bValue=['"]([^'"]+)['"]/, 1]
|
|
296
|
+
}
|
|
297
|
+
rescue
|
|
298
|
+
{}
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def sso_safe_slo_redirect_url(ctx, url, provider_id)
|
|
302
|
+
app_origin = ctx.context.base_url
|
|
303
|
+
callback_path = URI.parse("#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}").path
|
|
304
|
+
value = url.to_s
|
|
305
|
+
return app_origin if value.empty?
|
|
306
|
+
|
|
307
|
+
if value.start_with?("/") && !value.start_with?("//")
|
|
308
|
+
parsed = URI.parse(value)
|
|
309
|
+
return app_origin if parsed.path == callback_path
|
|
310
|
+
return value
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
return app_origin unless ctx.context.trusted_origin?(value, allow_relative_paths: false)
|
|
314
|
+
|
|
315
|
+
parsed = URI.parse(value)
|
|
316
|
+
return app_origin if parsed.path == callback_path
|
|
317
|
+
|
|
318
|
+
value
|
|
319
|
+
rescue
|
|
320
|
+
app_origin
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def sso_safe_saml_callback_url(ctx, url, provider_id)
|
|
324
|
+
app_origin = ctx.context.base_url
|
|
325
|
+
callback_path = URI.parse("#{ctx.context.base_url}/sso/saml2/callback/#{URI.encode_www_form_component(provider_id)}").path
|
|
326
|
+
acs_path = URI.parse("#{ctx.context.base_url}/sso/saml2/sp/acs/#{URI.encode_www_form_component(provider_id)}").path
|
|
327
|
+
value = url.to_s
|
|
328
|
+
return app_origin if value.empty?
|
|
329
|
+
|
|
330
|
+
if value.start_with?("/") && !value.start_with?("//")
|
|
331
|
+
parsed = URI.parse(value)
|
|
332
|
+
return app_origin if [callback_path, acs_path].include?(parsed.path)
|
|
333
|
+
return value
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
return app_origin unless ctx.context.trusted_origin?(value, allow_relative_paths: false)
|
|
337
|
+
|
|
338
|
+
parsed = URI.parse(value)
|
|
339
|
+
return app_origin if [callback_path, acs_path].include?(parsed.path)
|
|
340
|
+
|
|
341
|
+
value
|
|
342
|
+
rescue
|
|
343
|
+
app_origin
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def sso_signed_saml_redirect_query(provider, query)
|
|
347
|
+
saml_config = sso_provider_config_hash(provider["samlConfig"])
|
|
348
|
+
private_key = saml_config.dig(:sp_metadata, :private_key) || saml_config[:private_key] || saml_config[:sp_private_key]
|
|
349
|
+
raise APIError.new("BAD_REQUEST", message: "SAML Redirect signing requires privateKey") if private_key.to_s.empty?
|
|
350
|
+
|
|
351
|
+
sig_alg = saml_config[:signature_algorithm] ? sso_normalize_saml_signature_algorithm(saml_config[:signature_algorithm]) : XMLSecurity::Document::RSA_SHA256
|
|
352
|
+
signed = query.compact.merge(SigAlg: sig_alg)
|
|
353
|
+
signed_payload = signed.keys.map(&:to_s).select { |key| %w[SAMLRequest SAMLResponse RelayState SigAlg].include?(key) }.map { |key| [key, signed[key.to_sym] || signed[key]] }.reject { |_key, value| value.nil? }
|
|
354
|
+
signature_input = URI.encode_www_form(signed_payload)
|
|
355
|
+
signed[:Signature] = Base64.strict_encode64(OpenSSL::PKey.read(private_key).sign(sso_saml_signature_digest(sig_alg), signature_input))
|
|
356
|
+
signed
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def sso_saml_signature_digest(signature_algorithm)
|
|
360
|
+
case signature_algorithm.to_s
|
|
361
|
+
when /sha512/i
|
|
362
|
+
OpenSSL::Digest.new("SHA512")
|
|
363
|
+
when /sha384/i
|
|
364
|
+
OpenSSL::Digest.new("SHA384")
|
|
365
|
+
when /sha1/i
|
|
366
|
+
OpenSSL::Digest.new("SHA1")
|
|
367
|
+
else
|
|
368
|
+
OpenSSL::Digest.new("SHA256")
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def sso_saml_post_form(action, saml_param, saml_value, relay_state = nil)
|
|
373
|
+
relay_input = relay_state.to_s.empty? ? "" : "<input type=\"hidden\" name=\"RelayState\" value=\"#{CGI.escapeHTML(relay_state.to_s)}\" />"
|
|
374
|
+
html = "<!DOCTYPE html><html><body onload=\"document.forms[0].submit();\"><form method=\"POST\" action=\"#{CGI.escapeHTML(action.to_s)}\"><input type=\"hidden\" name=\"#{CGI.escapeHTML(saml_param.to_s)}\" value=\"#{CGI.escapeHTML(saml_value.to_s)}\" />#{relay_input}<noscript><input type=\"submit\" value=\"Continue\" /></noscript></form></body></html>"
|
|
375
|
+
[200, {"content-type" => "text/html"}, [html]]
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|