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.
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def sso_handle_saml_response(ctx, config = {})
8
+ provider = sso_find_saml_provider!(ctx, sso_fetch(ctx.params, :provider_id), config)
9
+ relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
10
+ state = sso_parse_saml_relay_state(ctx, relay_state) || {}
11
+ raw_response = sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response)
12
+ if ctx.method == "GET" && raw_response.to_s.empty?
13
+ session = Routes.current_session(ctx, allow_nil: true)
14
+ unless session
15
+ return sso_redirect(ctx, sso_append_error("#{ctx.context.base_url}/error", "invalid_request"))
16
+ end
17
+
18
+ return sso_redirect(ctx, sso_safe_saml_callback_url(ctx, relay_state || sso_saml_callback_url(provider) || "/", provider.fetch("providerId")))
19
+ end
20
+ max_response_size = config.dig(:saml, :max_response_size) || SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE
21
+ if raw_response.to_s.bytesize > max_response_size
22
+ raise APIError.new("BAD_REQUEST", message: "SAML response exceeds maximum allowed size (#{max_response_size} bytes)")
23
+ end
24
+ in_response_to_result = sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
25
+ return in_response_to_result if in_response_to_result.is_a?(Array)
26
+
27
+ assertion = sso_parse_saml_response(raw_response, config, provider, ctx)
28
+ assertion[:email_verified] = false unless config[:trust_email_verified]
29
+ sso_validate_saml_timestamp!(sso_saml_timestamp_conditions(assertion), config)
30
+ sso_validate_saml_response!(config, assertion, provider, ctx)
31
+ sso_consume_saml_in_response_to(ctx, in_response_to_result)
32
+ assertion_id = assertion[:id] || assertion["id"]
33
+ unless assertion_id.to_s.empty?
34
+ replay_key = "#{SSO_SAML_USED_ASSERTION_KEY_PREFIX}#{assertion_id}"
35
+ if ctx.context.internal_adapter.find_verification_value(replay_key)
36
+ callback_url = sso_safe_saml_callback_url(ctx, state["callbackURL"] || sso_saml_callback_url(provider) || "/", provider.fetch("providerId"))
37
+ return sso_redirect(ctx, sso_append_error(callback_url, "replay_detected", "SAML assertion has already been used"))
38
+ end
39
+ ctx.context.internal_adapter.create_verification_value(identifier: replay_key, value: "used", expiresAt: sso_saml_assertion_replay_expires_at(assertion, config))
40
+ end
41
+
42
+ callback_url = sso_safe_saml_callback_url(ctx, state["callbackURL"] || sso_saml_callback_url(provider) || "/", provider.fetch("providerId"))
43
+ email = (assertion[:email] || assertion["email"]).to_s.downcase
44
+ if config[:disable_implicit_sign_up] && !state["requestSignUp"] && !ctx.context.internal_adapter.find_user_by_email(email)
45
+ return sso_redirect(ctx, sso_append_error(callback_url, "signup disabled"))
46
+ end
47
+
48
+ result = sso_find_or_create_user_result(ctx, provider, assertion, config)
49
+ return sso_redirect(ctx, sso_append_error(callback_url, result.fetch(:error))) if result[:error]
50
+
51
+ user = result.fetch(:user)
52
+ if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
53
+ config[:provision_user].call(user: user, userInfo: assertion, provider: provider)
54
+ end
55
+ session = ctx.context.internal_adapter.create_session(user.fetch("id"))
56
+ sso_store_saml_session(ctx, provider, assertion, session) if config.dig(:saml, :enable_single_logout)
57
+ Cookies.set_session_cookie(ctx, {session: session, user: user})
58
+ sso_redirect(ctx, callback_url)
59
+ end
60
+
61
+ def sso_find_or_create_user(ctx, provider, user_info, config = {})
62
+ sso_find_or_create_user_result(ctx, provider, user_info, config).fetch(:user)
63
+ end
64
+
65
+ def sso_find_or_create_user_result(ctx, provider, user_info, config = {})
66
+ user_info = normalize_hash(user_info)
67
+ email = user_info[:email].to_s.downcase
68
+ account_id = (user_info[:id] || user_info["id"]).to_s
69
+ provider_id = provider.fetch("providerId")
70
+ storage_provider_id = provider["samlConfig"] ? provider_id : "sso:#{provider_id}"
71
+ existing_account = account_id.empty? ? nil : (
72
+ ctx.context.internal_adapter.find_account_by_provider_id(account_id, provider_id) ||
73
+ ctx.context.internal_adapter.find_account_by_provider_id(account_id, "sso:#{provider_id}")
74
+ )
75
+ if existing_account
76
+ user = ctx.context.internal_adapter.find_user_by_id(existing_account.fetch("userId"))
77
+ created = false
78
+ elsif (found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true))
79
+ already_linked_provider = Array(found[:accounts]).any? do |account|
80
+ [provider_id, "sso:#{provider_id}"].include?(account["providerId"])
81
+ end
82
+ if provider["samlConfig"]
83
+ return {error: "account_not_linked"} unless already_linked_provider || sso_saml_trusted_provider?(ctx, provider, email)
84
+ elsif !already_linked_provider && !sso_oidc_trusted_provider?(ctx, provider, email)
85
+ return {error: "account_not_linked"}
86
+ end
87
+
88
+ user = found[:user]
89
+ unless account_id.empty?
90
+ ctx.context.internal_adapter.create_account(
91
+ accountId: account_id,
92
+ providerId: storage_provider_id,
93
+ userId: user.fetch("id")
94
+ )
95
+ end
96
+ oidc_config = sso_provider_config_hash(provider["oidcConfig"])
97
+ if oidc_config[:override_user_info] || config[:default_override_user_info]
98
+ update = {}
99
+ update[:name] = user_info[:name] if user_info.key?(:name)
100
+ update[:image] = user_info[:image] if user_info.key?(:image)
101
+ update[:emailVerified] = !!user_info[:email_verified] if user_info.key?(:email_verified)
102
+ user = ctx.context.internal_adapter.update_user(user.fetch("id"), update) if update.any?
103
+ end
104
+ created = false
105
+ else
106
+ created = ctx.context.internal_adapter.create_user(
107
+ email: email,
108
+ name: user_info[:name] || email,
109
+ emailVerified: user_info.key?(:email_verified) ? user_info[:email_verified] : false,
110
+ image: user_info[:image]
111
+ )
112
+ ctx.context.internal_adapter.create_account(
113
+ accountId: account_id.empty? ? created.fetch("id") : account_id,
114
+ providerId: storage_provider_id,
115
+ userId: created.fetch("id")
116
+ )
117
+ user = created
118
+ created = true
119
+ end
120
+ sso_assign_organization_membership(ctx, provider, user, config)
121
+ {user: user, created: created}
122
+ end
123
+
124
+ def sso_saml_trusted_provider?(ctx, provider, email)
125
+ provider_id = provider.fetch("providerId")
126
+ linking = ctx.context.options.account[:account_linking] || {}
127
+ return false if linking[:enabled] == false
128
+
129
+ trusted = Array(linking[:trusted_providers]).map(&:to_s).include?(provider_id.to_s)
130
+ trusted || (provider["domainVerified"] && sso_email_domain_matches?(email, provider["domain"]))
131
+ end
132
+
133
+ def sso_oidc_trusted_provider?(ctx, provider, email)
134
+ provider_id = provider.fetch("providerId")
135
+ linking = ctx.context.options.account[:account_linking] || {}
136
+ return false if linking[:enabled] == false
137
+
138
+ trusted_providers = Array(linking[:trusted_providers]).map(&:to_s)
139
+ trusted_providers.include?(provider_id.to_s) ||
140
+ trusted_providers.include?("sso:#{provider_id}") ||
141
+ (provider["domainVerified"] && sso_email_domain_matches?(email, provider["domain"]))
142
+ end
143
+
144
+ def sso_assign_organization_membership(ctx, provider, user, config)
145
+ organization_id = provider["organizationId"]
146
+ return if organization_id.to_s.empty?
147
+ return if config.dig(:organization_provisioning, :disabled)
148
+ return unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
149
+ return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user.fetch("id")}])
150
+
151
+ role = if config.dig(:organization_provisioning, :get_role).respond_to?(:call)
152
+ config.dig(:organization_provisioning, :get_role).call(user: user, userInfo: {}, provider: provider)
153
+ else
154
+ config.dig(:organization_provisioning, :default_role) || config.dig(:organization_provisioning, :role) || "member"
155
+ end
156
+ ctx.context.adapter.create(model: "member", data: {organizationId: organization_id, userId: user.fetch("id"), role: role, createdAt: Time.now})
157
+ end
158
+
159
+ def sso_validate_saml_response!(config, assertion, provider, ctx)
160
+ validator = config.dig(:saml, :validate_response)
161
+ return unless validator.respond_to?(:call)
162
+ return if validator.call(response: assertion, provider: provider, context: ctx)
163
+
164
+ raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def sso_parse_saml_response(value, config = {}, provider = nil, ctx = nil)
8
+ parser = config.dig(:saml, :parse_response)
9
+ if parser.respond_to?(:call)
10
+ sso_validate_single_saml_assertion!(value) if sso_base64_xml?(value)
11
+ parsed = parser.call(raw_response: value.to_s, provider: provider, context: ctx)
12
+ return normalize_hash(parsed)
13
+ end
14
+
15
+ JSON.parse(Base64.decode64(value.to_s), symbolize_names: true)
16
+ rescue APIError
17
+ raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
18
+ rescue
19
+ raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
20
+ end
21
+
22
+ def sso_validate_single_saml_assertion!(saml_response)
23
+ xml = Base64.decode64(saml_response.to_s)
24
+ raise APIError.new("BAD_REQUEST", message: "Invalid base64-encoded SAML response") unless xml.include?("<")
25
+
26
+ assertions = xml.scan(/<(?:\w+:)?Assertion(?:\s|>|\/)/).length
27
+ encrypted_assertions = xml.scan(/<(?:\w+:)?EncryptedAssertion(?:\s|>|\/)/).length
28
+ total = assertions + encrypted_assertions
29
+ raise APIError.new("BAD_REQUEST", message: "SAML response contains no assertions") if total.zero?
30
+ if total > 1
31
+ raise APIError.new("BAD_REQUEST", message: "SAML response contains #{total} assertions, expected exactly 1")
32
+ end
33
+
34
+ true
35
+ rescue APIError
36
+ raise
37
+ rescue
38
+ raise APIError.new("BAD_REQUEST", message: "Invalid base64-encoded SAML response")
39
+ end
40
+
41
+ def sso_validate_saml_timestamp!(conditions, config = {}, now: Time.now.utc)
42
+ conditions = normalize_hash(conditions || {})
43
+ not_before = conditions[:not_before] || conditions[:notBefore]
44
+ not_on_or_after = conditions[:not_on_or_after] || conditions[:notOnOrAfter]
45
+ if not_before.to_s.empty? && not_on_or_after.to_s.empty?
46
+ raise APIError.new("BAD_REQUEST", message: "SAML assertion missing required timestamp conditions") if config.dig(:saml, :require_timestamps)
47
+
48
+ return true
49
+ end
50
+
51
+ clock_skew_seconds = ((config.dig(:saml, :clock_skew) || SSO_DEFAULT_CLOCK_SKEW_MS).to_f / 1000.0)
52
+ parsed_not_before = sso_parse_saml_timestamp(not_before, "SAML assertion has invalid NotBefore timestamp") unless not_before.to_s.empty?
53
+ parsed_not_on_or_after = sso_parse_saml_timestamp(not_on_or_after, "SAML assertion has invalid NotOnOrAfter timestamp") unless not_on_or_after.to_s.empty?
54
+
55
+ raise APIError.new("BAD_REQUEST", message: "SAML assertion is not yet valid") if parsed_not_before && now < (parsed_not_before - clock_skew_seconds)
56
+ raise APIError.new("BAD_REQUEST", message: "SAML assertion has expired") if parsed_not_on_or_after && now > (parsed_not_on_or_after + clock_skew_seconds)
57
+
58
+ true
59
+ end
60
+
61
+ def sso_parse_saml_timestamp(value, error_message)
62
+ Time.parse(value.to_s).utc
63
+ rescue
64
+ raise APIError.new("BAD_REQUEST", message: error_message)
65
+ end
66
+
67
+ def sso_saml_timestamp_conditions(assertion)
68
+ assertion = normalize_hash(assertion || {})
69
+ conditions = normalize_hash(assertion[:conditions] || {})
70
+ conditions[:not_before] ||= assertion[:not_before] || assertion[:notBefore]
71
+ conditions[:not_on_or_after] ||= assertion[:not_on_or_after] || assertion[:notOnOrAfter]
72
+ conditions
73
+ end
74
+
75
+ def sso_base64_xml?(value)
76
+ Base64.decode64(value.to_s).lstrip.start_with?("<")
77
+ rescue
78
+ false
79
+ end
80
+
81
+ def sso_validate_saml_algorithms!(xml, options = {})
82
+ on_deprecated = (options[:on_deprecated] || "warn").to_s
83
+ signature_algorithms = xml.to_s.scan(/SignatureMethod[^>]+Algorithm=["']([^"']+)["']/).flatten.map { |algorithm| sso_normalize_saml_signature_algorithm(algorithm) }
84
+ digest_algorithms = xml.to_s.scan(/DigestMethod[^>]+Algorithm=["']([^"']+)["']/).flatten.map { |algorithm| sso_normalize_saml_digest_algorithm(algorithm) }
85
+ key_encryption_algorithms = xml.to_s.scan(/<[^\/>]*EncryptedKey\b[\s\S]*?EncryptionMethod[^>]+Algorithm=["']([^"']+)["']/).flatten
86
+ data_encryption_algorithms = xml.to_s.scan(/<[^\/>]*EncryptedData\b[\s\S]*?EncryptionMethod[^>]+Algorithm=["']([^"']+)["']/).flatten
87
+
88
+ sso_validate_saml_algorithm_group!(
89
+ signature_algorithms,
90
+ allowed: options[:allowed_signature_algorithms]&.map { |algorithm| sso_normalize_saml_signature_algorithm(algorithm) },
91
+ secure: SSO_SAML_SECURE_SIGNATURE_ALGORITHMS,
92
+ deprecated: ["http://www.w3.org/2000/09/xmldsig#rsa-sha1"],
93
+ on_deprecated: on_deprecated,
94
+ label: "signature"
95
+ )
96
+ sso_validate_saml_algorithm_group!(
97
+ digest_algorithms,
98
+ allowed: options[:allowed_digest_algorithms]&.map { |algorithm| sso_normalize_saml_digest_algorithm(algorithm) },
99
+ secure: SSO_SAML_SECURE_DIGEST_ALGORITHMS,
100
+ deprecated: ["http://www.w3.org/2000/09/xmldsig#sha1"],
101
+ on_deprecated: on_deprecated,
102
+ label: "digest"
103
+ )
104
+ sso_validate_saml_algorithm_group!(
105
+ key_encryption_algorithms,
106
+ allowed: options[:allowed_key_encryption_algorithms],
107
+ secure: SSO_SAML_SECURE_KEY_ENCRYPTION_ALGORITHMS,
108
+ deprecated: ["http://www.w3.org/2001/04/xmlenc#rsa-1_5"],
109
+ on_deprecated: on_deprecated,
110
+ label: "key encryption"
111
+ )
112
+ sso_validate_saml_algorithm_group!(
113
+ data_encryption_algorithms,
114
+ allowed: options[:allowed_data_encryption_algorithms],
115
+ secure: SSO_SAML_SECURE_DATA_ENCRYPTION_ALGORITHMS,
116
+ deprecated: ["http://www.w3.org/2001/04/xmlenc#tripledes-cbc"],
117
+ on_deprecated: on_deprecated,
118
+ label: "data encryption"
119
+ )
120
+
121
+ true
122
+ end
123
+
124
+ def sso_normalize_saml_signature_algorithm(algorithm)
125
+ SSO_SAML_SIGNATURE_ALGORITHMS.fetch(algorithm.to_s.downcase, algorithm.to_s)
126
+ end
127
+
128
+ def sso_normalize_saml_digest_algorithm(algorithm)
129
+ SSO_SAML_DIGEST_ALGORITHMS.fetch(algorithm.to_s.downcase, algorithm.to_s)
130
+ end
131
+
132
+ def sso_validate_saml_algorithm_group!(algorithms, allowed:, secure:, deprecated:, on_deprecated:, label:)
133
+ algorithms.each do |algorithm|
134
+ if allowed
135
+ next if allowed.include?(algorithm)
136
+
137
+ raise APIError.new("BAD_REQUEST", message: "SAML #{label} algorithm not in allow-list: #{algorithm}")
138
+ end
139
+
140
+ if deprecated.include?(algorithm)
141
+ raise APIError.new("BAD_REQUEST", message: "SAML response uses deprecated #{label} algorithm: #{algorithm}") if on_deprecated == "reject"
142
+ next
143
+ end
144
+ next if secure.include?(algorithm)
145
+
146
+ raise APIError.new("BAD_REQUEST", message: "SAML #{label} algorithm not recognized: #{algorithm}")
147
+ end
148
+ end
149
+
150
+ def sso_generate_saml_relay_state(ctx, state_data)
151
+ ttl_ms = 10 * 60 * 1000
152
+ relay_state = BetterAuth::Crypto.random_string(32)
153
+ now_ms = (Time.now.to_f * 1000).to_i
154
+ stored = state_data.each_with_object({}) { |(key, value), result| result[key.to_s] = value }.merge(
155
+ "codeVerifier" => BetterAuth::Crypto.random_string(128),
156
+ "expiresAt" => now_ms + ttl_ms
157
+ )
158
+ ctx.context.internal_adapter.create_verification_value(
159
+ identifier: "#{SSO_SAML_RELAY_STATE_KEY_PREFIX}#{relay_state}",
160
+ value: JSON.generate(stored),
161
+ expiresAt: Time.at((now_ms + ttl_ms) / 1000.0)
162
+ )
163
+ ctx.set_signed_cookie("relay_state", relay_state, ctx.context.secret, path: "/", max_age: ttl_ms / 1000, http_only: true, same_site: "lax")
164
+ relay_state
165
+ end
166
+
167
+ def sso_parse_saml_relay_state(ctx, relay_state)
168
+ state = sso_verify_state(relay_state, ctx.context.secret)
169
+ return state if state
170
+
171
+ verification = ctx.context.internal_adapter.find_verification_value("#{SSO_SAML_RELAY_STATE_KEY_PREFIX}#{relay_state}")
172
+ return nil unless verification
173
+ return nil unless sso_future_time?(verification.fetch("expiresAt"))
174
+
175
+ parsed = JSON.parse(verification.fetch("value"))
176
+ return nil if parsed["expiresAt"].to_i <= (Time.now.to_f * 1000).to_i
177
+
178
+ parsed
179
+ rescue
180
+ nil
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ module Routes
6
+ module SAMLPipeline
7
+ module_function
8
+
9
+ def process_response(ctx, config = {})
10
+ BetterAuth::Plugins.sso_handle_saml_response(ctx, config)
11
+ end
12
+
13
+ def safe_redirect_url(ctx, url, provider_id)
14
+ BetterAuth::Plugins.sso_safe_slo_redirect_url(ctx, url, provider_id)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ module SAML
6
+ module Algorithms
7
+ module_function
8
+
9
+ SignatureAlgorithm = BetterAuth::Plugins::SSO_SAML_SIGNATURE_ALGORITHMS.merge(
10
+ RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
11
+ RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
12
+ RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
13
+ RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
14
+ ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
15
+ ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
16
+ ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
17
+ ).freeze
18
+ DigestAlgorithm = BetterAuth::Plugins::SSO_SAML_DIGEST_ALGORITHMS.merge(
19
+ SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
20
+ SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
21
+ SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
22
+ SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
23
+ ).freeze
24
+ KEY_ENCRYPTION_ALGORITHM = {
25
+ RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
26
+ RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
27
+ RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
28
+ }.freeze
29
+ DATA_ENCRYPTION_ALGORITHM = {
30
+ TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
31
+ AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
32
+ AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
33
+ AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
34
+ AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
35
+ AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
36
+ AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
37
+ }.freeze
38
+ SecureSignatureAlgorithms = BetterAuth::Plugins::SSO_SAML_SECURE_SIGNATURE_ALGORITHMS
39
+ SecureDigestAlgorithms = BetterAuth::Plugins::SSO_SAML_SECURE_DIGEST_ALGORITHMS
40
+
41
+ def validate(xml, **options)
42
+ if xml.is_a?(Hash)
43
+ return validate_response(
44
+ sig_alg: xml[:sig_alg] || xml[:sigAlg] || xml["sig_alg"] || xml["sigAlg"],
45
+ saml_content: xml[:saml_content] || xml[:samlContent] || xml["saml_content"] || xml["samlContent"],
46
+ **options
47
+ )
48
+ end
49
+
50
+ BetterAuth::Plugins.sso_validate_saml_algorithms!(xml, normalize_options(options))
51
+ end
52
+
53
+ def validate_response(sig_alg: nil, saml_content: "", **options)
54
+ xml = +""
55
+ xml << "<ds:SignatureMethod Algorithm=\"#{sig_alg}\"/>" unless sig_alg.to_s.empty?
56
+ xml << saml_content.to_s
57
+ BetterAuth::Plugins.sso_validate_saml_algorithms!(xml, normalize_options(options))
58
+ end
59
+
60
+ def validate_config(config = {}, **options)
61
+ config_keys = %i[signature_algorithm signatureAlgorithm digest_algorithm digestAlgorithm]
62
+ inline_config = options.slice(*config_keys)
63
+ normalized = BetterAuth::Plugins.normalize_hash((config || {}).merge(inline_config))
64
+ options = options.except(*config_keys)
65
+ xml = +""
66
+ unless normalized[:signature_algorithm].to_s.empty?
67
+ xml << "<ds:SignatureMethod Algorithm=\"#{normalized[:signature_algorithm]}\"/>"
68
+ end
69
+ unless normalized[:digest_algorithm].to_s.empty?
70
+ xml << "<ds:DigestMethod Algorithm=\"#{normalized[:digest_algorithm]}\"/>"
71
+ end
72
+ BetterAuth::Plugins.sso_validate_saml_algorithms!(xml, normalize_options(options))
73
+ end
74
+
75
+ def normalize_signature(algorithm)
76
+ BetterAuth::Plugins.sso_normalize_saml_signature_algorithm(algorithm)
77
+ end
78
+
79
+ def normalize_digest(algorithm)
80
+ BetterAuth::Plugins.sso_normalize_saml_digest_algorithm(algorithm)
81
+ end
82
+
83
+ def normalize_options(options)
84
+ normalized = BetterAuth::Plugins.normalize_hash(options || {})
85
+ {
86
+ on_deprecated: normalized[:on_deprecated],
87
+ allowed_signature_algorithms: normalized[:allowed_signature_algorithms],
88
+ allowed_digest_algorithms: normalized[:allowed_digest_algorithms],
89
+ allowed_key_encryption_algorithms: normalized[:allowed_key_encryption_algorithms],
90
+ allowed_data_encryption_algorithms: normalized[:allowed_data_encryption_algorithms]
91
+ }.compact
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ module SAML
6
+ module Assertions
7
+ module_function
8
+
9
+ def validate_single_assertion!(saml_response)
10
+ BetterAuth::Plugins.sso_validate_single_saml_assertion!(saml_response)
11
+ end
12
+
13
+ def count(xml)
14
+ assertions = xml.to_s.scan(/<(?:\w+:)?Assertion(?:\s|>|\/)/).length
15
+ encrypted_assertions = xml.to_s.scan(/<(?:\w+:)?EncryptedAssertion(?:\s|>|\/)/).length
16
+ {assertions: assertions, encrypted_assertions: encrypted_assertions, total: assertions + encrypted_assertions}
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ module SAML
6
+ module ErrorCodes
7
+ SAML_ERROR_CODES = {
8
+ single_logout_not_enabled: "Single Logout is not enabled",
9
+ invalid_logout_response: "Invalid LogoutResponse",
10
+ invalid_logout_request: "Invalid LogoutRequest",
11
+ logout_failed_at_idp: "Logout failed at IdP",
12
+ idp_slo_not_supported: "IdP does not support Single Logout Service",
13
+ saml_provider_not_found: "SAML provider not found"
14
+ }.freeze
15
+
16
+ module_function
17
+
18
+ def message(code)
19
+ SAML_ERROR_CODES[BetterAuth::Plugins.normalize_key(code)]
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ module SAML
6
+ module Parser
7
+ module_function
8
+
9
+ def parse_response(value, config = {}, provider = nil, ctx = nil)
10
+ BetterAuth::Plugins.sso_parse_saml_response(value, config, provider, ctx)
11
+ end
12
+
13
+ def base64_xml?(value)
14
+ BetterAuth::Plugins.sso_base64_xml?(value)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ module SAML
6
+ module Timestamp
7
+ module_function
8
+
9
+ def validate!(conditions, config = {}, now: Time.now.utc)
10
+ BetterAuth::Plugins.sso_validate_saml_timestamp!(conditions, config, now: now)
11
+ end
12
+
13
+ def conditions(assertion)
14
+ BetterAuth::Plugins.sso_saml_timestamp_conditions(assertion)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end