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 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
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Split SAML SP code out of `better_auth-sso` into a dedicated gem with `ruby-saml` as the only XML dependency surface.
6
+
7
+ ## 0.10.0
8
+
9
+ - Initial release (extracted from `better_auth-sso` 0.10.0).
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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sso/plugin/saml_response"
4
+ require_relative "../sso/plugin/saml_metadata_and_logout"
5
+ require_relative "../sso/plugin/saml_validation_and_state"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SAML
5
+ VERSION = "0.10.0"
6
+ end
7
+ end
@@ -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