better_auth-sso 0.6.2 → 0.8.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,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def sso_register_provider_endpoint(config = {})
8
+ Endpoint.new(path: "/sso/register", method: "POST") do |ctx|
9
+ session = Routes.current_session(ctx)
10
+ body = normalize_hash(ctx.body)
11
+ provider_id = body[:provider_id].to_s
12
+ raise APIError.new("BAD_REQUEST", message: "providerId is required") if provider_id.empty?
13
+
14
+ limit = sso_provider_limit(session.fetch(:user), config)
15
+ if limit.to_i.zero?
16
+ raise APIError.new("FORBIDDEN", message: "SSO provider registration is disabled")
17
+ end
18
+ providers = ctx.context.adapter.find_many(model: "ssoProvider", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
19
+ if providers.length >= limit.to_i
20
+ raise APIError.new("FORBIDDEN", message: "You have reached the maximum number of SSO providers")
21
+ end
22
+
23
+ sso_validate_url!(body[:issuer], "Invalid issuer. Must be a valid URL")
24
+ sso_validate_organization_membership!(ctx, session.fetch(:user).fetch("id"), body[:organization_id]) if body[:organization_id]
25
+ if ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id}])
26
+ raise APIError.new("UNPROCESSABLE_ENTITY", message: "SSO provider with this providerId already exists")
27
+ end
28
+
29
+ oidc_config = normalize_hash(body[:oidc_config] || {})
30
+ oidc_config = sso_hydrate_oidc_config(body[:issuer], oidc_config, ctx) if oidc_config.any? && !oidc_config[:skip_discovery]
31
+ oidc_config[:override_user_info] = !!(body[:override_user_info] || config[:default_override_user_info]) if oidc_config.any?
32
+ saml_config = normalize_hash(body[:saml_config] || {})
33
+ sso_validate_saml_config!(saml_config, config) unless saml_config.empty?
34
+
35
+ provider = ctx.context.adapter.create(
36
+ model: "ssoProvider",
37
+ data: {
38
+ providerId: provider_id,
39
+ issuer: body[:issuer].to_s,
40
+ domain: body[:domain].to_s.downcase,
41
+ oidcConfig: oidc_config.empty? ? nil : oidc_config,
42
+ samlConfig: saml_config.empty? ? nil : saml_config,
43
+ userId: session.fetch(:user).fetch("id"),
44
+ organizationId: body[:organization_id],
45
+ domainVerified: false
46
+ }
47
+ )
48
+ domain_verification_token = nil
49
+ if config.dig(:domain_verification, :enabled)
50
+ domain_verification_token = BetterAuth::Crypto.random_string(24)
51
+ ctx.context.internal_adapter.create_verification_value(
52
+ identifier: sso_domain_verification_identifier(config, provider.fetch("providerId")),
53
+ value: domain_verification_token,
54
+ expiresAt: Time.now + (7 * 24 * 60 * 60)
55
+ )
56
+ end
57
+ response = sso_sanitize_provider(provider, ctx.context)
58
+ response[:redirectURI] = sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId"))
59
+ response[:domainVerificationToken] = domain_verification_token if domain_verification_token
60
+ ctx.json(response)
61
+ end
62
+ end
63
+
64
+ def sso_list_providers_endpoint
65
+ Endpoint.new(path: "/sso/providers", method: "GET") do |ctx|
66
+ session = Routes.current_session(ctx)
67
+ providers = ctx.context.adapter.find_many(model: "ssoProvider")
68
+ .select { |provider| sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx) }
69
+ .map { |provider| sso_sanitize_provider(provider, ctx.context) }
70
+ ctx.json({providers: providers})
71
+ end
72
+ end
73
+
74
+ def sso_get_provider_endpoint
75
+ Endpoint.new(path: "/sso/get-provider", method: "GET") do |ctx|
76
+ session = Routes.current_session(ctx)
77
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.query, :provider_id) || sso_fetch(ctx.params, :provider_id))
78
+ raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
79
+
80
+ ctx.json(sso_sanitize_provider(provider, ctx.context))
81
+ end
82
+ end
83
+
84
+ def sso_update_provider_endpoint(config = {})
85
+ Endpoint.new(path: "/sso/update-provider", method: "POST") do |ctx|
86
+ session = Routes.current_session(ctx)
87
+ body = normalize_hash(ctx.body)
88
+ provider = sso_find_provider!(ctx, sso_fetch(body, :provider_id) || sso_fetch(ctx.params, :provider_id))
89
+ raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
90
+
91
+ if !body.key?(:issuer) && !body.key?(:domain) && !body.key?(:oidc_config) && !body.key?(:saml_config)
92
+ raise APIError.new("BAD_REQUEST", message: "No fields provided for update")
93
+ end
94
+ sso_validate_url!(body[:issuer], "Invalid issuer. Must be a valid URL") if body.key?(:issuer)
95
+ update = {}
96
+ update[:issuer] = body[:issuer] if body.key?(:issuer)
97
+ update[:domain] = body[:domain].to_s.downcase if body.key?(:domain)
98
+ update[:domainVerified] = false if body.key?(:domain) && body[:domain].to_s.downcase != provider["domain"].to_s
99
+ if body.key?(:oidc_config)
100
+ current = sso_provider_config_hash(provider["oidcConfig"])
101
+ raise APIError.new("BAD_REQUEST", message: "Cannot update OIDC config for a provider that doesn't have OIDC configured") if current.empty?
102
+
103
+ resolved_issuer = update[:issuer] || current[:issuer] || provider["issuer"]
104
+ update[:oidcConfig] = current.merge(normalize_hash(body[:oidc_config])).merge(issuer: resolved_issuer).compact
105
+ end
106
+ if body.key?(:saml_config)
107
+ current = sso_provider_config_hash(provider["samlConfig"])
108
+ raise APIError.new("BAD_REQUEST", message: "Cannot update SAML config for a provider that doesn't have SAML configured") if current.empty?
109
+
110
+ resolved_issuer = update[:issuer] || current[:issuer] || provider["issuer"]
111
+ merged_saml_config = current.merge(normalize_hash(body[:saml_config])).merge(issuer: resolved_issuer).compact
112
+ sso_validate_saml_config!(merged_saml_config, config)
113
+ update[:samlConfig] = merged_saml_config
114
+ end
115
+ updated = ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: update)
116
+ ctx.json(sso_sanitize_provider(updated, ctx.context))
117
+ end
118
+ end
119
+
120
+ def sso_delete_provider_endpoint
121
+ Endpoint.new(path: "/sso/delete-provider", method: "POST") do |ctx|
122
+ session = Routes.current_session(ctx)
123
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.body, :provider_id) || sso_fetch(ctx.params, :provider_id))
124
+ raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
125
+
126
+ ctx.context.adapter.delete(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}])
127
+ ctx.json({success: true})
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,352 @@
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
+ max_metadata_size = plugin_config.dig(:saml, :max_metadata_size) || SSO_DEFAULT_MAX_SAML_METADATA_SIZE
10
+ if metadata.to_s.bytesize > max_metadata_size
11
+ raise APIError.new("BAD_REQUEST", message: "IdP metadata exceeds maximum allowed size (#{max_metadata_size} bytes)")
12
+ end
13
+
14
+ if saml_config[:entry_point].to_s.empty? && saml_config[:single_sign_on_service].to_s.empty? && metadata.to_s.empty?
15
+ raise APIError.new("BAD_REQUEST", message: "SAML config must include entryPoint, singleSignOnService, or IdP metadata")
16
+ end
17
+ sso_validate_url!(saml_config[:entry_point], "SAML entryPoint must be a valid URL") unless saml_config[:entry_point].to_s.empty?
18
+ unless saml_config[:single_sign_on_service].to_s.empty?
19
+ sso_validate_url!(saml_config[:single_sign_on_service], "SAML singleSignOnService must be a valid URL")
20
+ end
21
+ unless saml_config[:single_logout_service].to_s.empty?
22
+ sso_validate_url!(saml_config[:single_logout_service], "SAML singleLogoutService must be a valid URL")
23
+ end
24
+
25
+ config_algorithm_xml = +""
26
+ unless saml_config[:signature_algorithm].to_s.empty?
27
+ config_algorithm_xml << "<ds:SignatureMethod Algorithm=\"#{saml_config[:signature_algorithm]}\"/>"
28
+ end
29
+ unless saml_config[:digest_algorithm].to_s.empty?
30
+ config_algorithm_xml << "<ds:DigestMethod Algorithm=\"#{saml_config[:digest_algorithm]}\"/>"
31
+ end
32
+ sso_validate_saml_algorithms!(
33
+ config_algorithm_xml,
34
+ on_deprecated: plugin_config.dig(:saml, :algorithms, :on_deprecated) || saml_config[:on_deprecated_algorithm] || "warn",
35
+ allowed_signature_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_signature_algorithms) || saml_config[:allowed_signature_algorithms],
36
+ allowed_digest_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_digest_algorithms) || saml_config[:allowed_digest_algorithms],
37
+ allowed_key_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_key_encryption_algorithms) || saml_config[:allowed_key_encryption_algorithms],
38
+ allowed_data_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_data_encryption_algorithms) || saml_config[:allowed_data_encryption_algorithms]
39
+ )
40
+ sso_validate_saml_algorithms!(
41
+ metadata.to_s,
42
+ on_deprecated: plugin_config.dig(:saml, :algorithms, :on_deprecated) || saml_config[:on_deprecated_algorithm] || "warn",
43
+ allowed_signature_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_signature_algorithms) || saml_config[:allowed_signature_algorithms],
44
+ allowed_digest_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_digest_algorithms) || saml_config[:allowed_digest_algorithms],
45
+ allowed_key_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_key_encryption_algorithms) || saml_config[:allowed_key_encryption_algorithms],
46
+ allowed_data_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_data_encryption_algorithms) || saml_config[:allowed_data_encryption_algorithms]
47
+ )
48
+ end
49
+
50
+ def sso_sp_metadata_xml(ctx, provider, config = {})
51
+ provider_id = provider.fetch("providerId")
52
+ saml_config = normalize_hash(provider["samlConfig"] || {})
53
+ explicit_metadata = saml_config.dig(:sp_metadata, :metadata)
54
+ return explicit_metadata unless explicit_metadata.to_s.empty?
55
+
56
+ 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)}"
57
+ acs_url = sso_saml_acs_url(ctx, provider)
58
+ authn_requests_signed = !!saml_config[:authn_requests_signed]
59
+ want_assertions_signed = saml_config.key?(:want_assertions_signed) ? !!saml_config[:want_assertions_signed] : true
60
+ name_id_format = saml_config[:identifier_format].to_s.empty? ? "" : "<NameIDFormat>#{saml_config[:identifier_format]}</NameIDFormat>"
61
+ slo = if config.dig(:saml, :enable_single_logout)
62
+ location = "#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}"
63
+ "<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}\" />"
64
+ end
65
+
66
+ "<EntityDescriptor entityID=\"#{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=\"#{acs_url}\" index=\"0\" /></SPSSODescriptor></EntityDescriptor>"
67
+ end
68
+
69
+ def sso_saml_acs_url(ctx, provider)
70
+ provider_id = provider.fetch("providerId")
71
+ base_url = ctx.context.base_url
72
+ configured = normalize_hash(provider["samlConfig"] || {})[:callback_url].to_s
73
+ return configured if sso_saml_acs_url?(configured)
74
+
75
+ "#{base_url}/sso/saml2/sp/acs/#{URI.encode_www_form_component(provider_id)}"
76
+ end
77
+
78
+ def sso_saml_acs_url?(url)
79
+ return false if url.to_s.empty?
80
+
81
+ URI.parse(url.to_s).path.include?("/sso/saml2/sp/acs")
82
+ rescue
83
+ false
84
+ end
85
+
86
+ def sso_saml_idp_metadata(provider_or_config)
87
+ saml_config = if provider_or_config.respond_to?(:key?) && (provider_or_config.key?("samlConfig") || provider_or_config.key?(:samlConfig))
88
+ normalize_hash(provider_or_config["samlConfig"] || provider_or_config[:samlConfig] || {})
89
+ else
90
+ normalize_hash(provider_or_config || {})
91
+ end
92
+ idp_metadata = normalize_hash(saml_config[:idp_metadata] || {})
93
+ xml = idp_metadata[:metadata] || saml_config[:metadata] || saml_config[:idp_metadata_xml]
94
+ parsed = xml.to_s.strip.empty? ? {} : sso_parse_saml_metadata_xml(xml)
95
+ parsed[:entity_id] ||= idp_metadata[:entity_id] || idp_metadata[:entityID] || saml_config[:issuer]
96
+ parsed[:cert] ||= idp_metadata[:cert] || saml_config[:cert]
97
+ 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?
98
+ 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?
99
+ parsed
100
+ end
101
+
102
+ def sso_parse_saml_metadata_xml(xml)
103
+ doc = REXML::Document.new(xml.to_s)
104
+ root = doc.root
105
+ {
106
+ entity_id: root&.attributes&.[]("entityID"),
107
+ cert: sso_saml_normalize_certificate(sso_saml_metadata_first_text(doc, "X509Certificate")),
108
+ single_sign_on_service: sso_saml_metadata_services(doc, "SingleSignOnService"),
109
+ single_logout_service: sso_saml_metadata_services(doc, "SingleLogoutService")
110
+ }.compact
111
+ rescue
112
+ {}
113
+ end
114
+
115
+ def sso_saml_metadata_services(doc, element_name)
116
+ services = []
117
+ REXML::XPath.each(doc, "//*") do |element|
118
+ next unless element.name == element_name
119
+
120
+ services << {
121
+ binding: element.attributes["Binding"],
122
+ location: element.attributes["Location"]
123
+ }.compact
124
+ end
125
+ services
126
+ end
127
+
128
+ def sso_saml_metadata_first_text(doc, element_name)
129
+ REXML::XPath.each(doc, "//*") do |element|
130
+ return element.text.to_s.strip if element.name == element_name && !element.text.to_s.strip.empty?
131
+ end
132
+ nil
133
+ end
134
+
135
+ def sso_saml_metadata_services_from_config(value)
136
+ Array(value).filter_map do |entry|
137
+ data = normalize_hash(entry || {})
138
+ next if data[:location].to_s.empty?
139
+
140
+ {binding: data[:binding] || data[:Binding], location: data[:location] || data[:Location]}.compact
141
+ end
142
+ end
143
+
144
+ def sso_saml_preferred_service(services)
145
+ Array(services).find { |service| normalize_hash(service)[:binding].to_s.include?("HTTP-Redirect") } || Array(services).first
146
+ end
147
+
148
+ def sso_saml_normalize_certificate(value)
149
+ cert = value.to_s.strip
150
+ return nil if cert.empty?
151
+ return cert if cert.include?("BEGIN CERTIFICATE")
152
+
153
+ "-----BEGIN CERTIFICATE-----\n#{cert.scan(/.{1,64}/).join("\n")}\n-----END CERTIFICATE-----"
154
+ end
155
+
156
+ def sso_saml_callback_url(provider)
157
+ saml_config = normalize_hash(provider["samlConfig"] || {})
158
+ saml_config[:callback_url]
159
+ end
160
+
161
+ def sso_saml_logout_destination(provider)
162
+ saml_config = normalize_hash(provider["samlConfig"] || {})
163
+ direct = saml_config[:single_logout_service] ||
164
+ saml_config[:single_logout_service_url] ||
165
+ saml_config[:idp_slo_service_url] ||
166
+ saml_config[:logout_url]
167
+ return direct unless direct.to_s.empty?
168
+
169
+ service = sso_saml_preferred_service(sso_saml_idp_metadata(saml_config)[:single_logout_service])
170
+ normalize_hash(service || {})[:location]
171
+ end
172
+
173
+ def sso_store_saml_session(ctx, provider, assertion, session)
174
+ name_id = assertion[:name_id] || assertion[:nameid] || assertion[:email]
175
+ session_index = assertion[:session_index] || assertion[:sessionindex] || assertion[:id]
176
+ return if name_id.to_s.empty? || session_index.to_s.empty?
177
+
178
+ record = {
179
+ providerId: provider.fetch("providerId"),
180
+ sessionToken: session.fetch("token"),
181
+ userId: session.fetch("userId"),
182
+ nameId: name_id.to_s,
183
+ sessionIndex: session_index.to_s
184
+ }
185
+ expires_at = session["expiresAt"] || Time.now + (SSO_DEFAULT_ASSERTION_TTL_MS / 1000.0)
186
+ value = JSON.generate(record)
187
+ session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{name_id}"
188
+ ctx.context.internal_adapter.create_verification_value(
189
+ identifier: session_identifier,
190
+ value: value,
191
+ expiresAt: expires_at
192
+ )
193
+ ctx.context.internal_adapter.create_verification_value(
194
+ identifier: "#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session.fetch("token")}",
195
+ value: session_identifier,
196
+ expiresAt: expires_at
197
+ )
198
+ end
199
+
200
+ def sso_process_saml_logout_request(ctx, provider, raw_request)
201
+ data = sso_parse_saml_logout_request(raw_request)
202
+ return data if data[:name_id].to_s.empty?
203
+
204
+ session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{data[:name_id]}"
205
+ verification = ctx.context.internal_adapter.find_verification_value(session_identifier)
206
+ return data unless verification
207
+
208
+ record = JSON.parse(verification.fetch("value"))
209
+ session_token = record["sessionToken"]
210
+ session_index_matches = data[:session_index].to_s.empty? || record["sessionIndex"].to_s.empty? || data[:session_index].to_s == record["sessionIndex"].to_s
211
+ ctx.context.internal_adapter.delete_session(session_token) if session_token && session_index_matches
212
+ ctx.context.internal_adapter.delete_verification_by_identifier(session_identifier)
213
+ ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}") if session_token
214
+ data
215
+ rescue
216
+ {}
217
+ end
218
+
219
+ def sso_store_saml_logout_request(ctx, provider, request_id, config)
220
+ ttl_ms = (config.dig(:saml, :logout_request_ttl) || SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS).to_i
221
+ ctx.context.internal_adapter.create_verification_value(
222
+ identifier: "#{SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX}#{request_id}",
223
+ value: provider.fetch("providerId"),
224
+ expiresAt: Time.now + (ttl_ms / 1000.0)
225
+ )
226
+ end
227
+
228
+ def sso_process_saml_logout_response(ctx, raw_response)
229
+ data = sso_parse_saml_logout_response(raw_response)
230
+ status_code = data[:status_code]
231
+ if status_code && status_code != SSO_SAML_STATUS_SUCCESS
232
+ raise APIError.new("BAD_REQUEST", message: "Logout failed at IdP")
233
+ end
234
+
235
+ in_response_to = data[:in_response_to]
236
+ return if in_response_to.to_s.empty?
237
+
238
+ ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX}#{in_response_to}")
239
+ end
240
+
241
+ def sso_parse_saml_logout_request(raw_request)
242
+ xml = Base64.decode64(raw_request.to_s.gsub(/\s+/, ""))
243
+ {
244
+ id: xml[/\bID=['"]([^'"]+)['"]/, 1],
245
+ name_id: xml[%r{<(?:\w+:)?NameID[^>]*>([^<]+)</(?:\w+:)?NameID>}, 1],
246
+ session_index: xml[%r{<(?:\w+:)?SessionIndex[^>]*>([^<]+)</(?:\w+:)?SessionIndex>}, 1]
247
+ }
248
+ rescue
249
+ {}
250
+ end
251
+
252
+ def sso_validate_saml_slo_signature!(ctx, raw_message, error_message)
253
+ return true if !sso_fetch(ctx.body, :signature).to_s.empty? || !sso_fetch(ctx.query, :signature).to_s.empty?
254
+
255
+ xml = Base64.decode64(raw_message.to_s.gsub(/\s+/, ""))
256
+ return true if xml.include?("<Signature") || xml.include?(":Signature")
257
+
258
+ raise APIError.new("BAD_REQUEST", message: error_message)
259
+ rescue APIError
260
+ raise
261
+ rescue
262
+ raise APIError.new("BAD_REQUEST", message: error_message)
263
+ end
264
+
265
+ def sso_parse_saml_logout_response(raw_response)
266
+ xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
267
+ {
268
+ in_response_to: xml[/\bInResponseTo=['"]([^'"]+)['"]/, 1],
269
+ status_code: xml[/<(?:\w+:)?StatusCode\b[^>]*\bValue=['"]([^'"]+)['"]/, 1]
270
+ }
271
+ rescue
272
+ {}
273
+ end
274
+
275
+ def sso_safe_slo_redirect_url(ctx, url, provider_id)
276
+ app_origin = ctx.context.base_url
277
+ callback_path = URI.parse("#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}").path
278
+ value = url.to_s
279
+ return app_origin if value.empty?
280
+
281
+ if value.start_with?("/") && !value.start_with?("//")
282
+ parsed = URI.parse(value)
283
+ return app_origin if parsed.path == callback_path
284
+ return value
285
+ end
286
+
287
+ return app_origin unless ctx.context.trusted_origin?(value, allow_relative_paths: false)
288
+
289
+ parsed = URI.parse(value)
290
+ return app_origin if parsed.path == callback_path
291
+
292
+ value
293
+ rescue
294
+ app_origin
295
+ end
296
+
297
+ def sso_safe_saml_callback_url(ctx, url, provider_id)
298
+ app_origin = ctx.context.base_url
299
+ callback_path = URI.parse("#{ctx.context.base_url}/sso/saml2/callback/#{URI.encode_www_form_component(provider_id)}").path
300
+ acs_path = URI.parse("#{ctx.context.base_url}/sso/saml2/sp/acs/#{URI.encode_www_form_component(provider_id)}").path
301
+ value = url.to_s
302
+ return app_origin if value.empty?
303
+
304
+ if value.start_with?("/") && !value.start_with?("//")
305
+ parsed = URI.parse(value)
306
+ return app_origin if [callback_path, acs_path].include?(parsed.path)
307
+ return value
308
+ end
309
+
310
+ return app_origin unless ctx.context.trusted_origin?(value, allow_relative_paths: false)
311
+
312
+ parsed = URI.parse(value)
313
+ return app_origin if [callback_path, acs_path].include?(parsed.path)
314
+
315
+ value
316
+ rescue
317
+ app_origin
318
+ end
319
+
320
+ def sso_signed_saml_redirect_query(provider, query)
321
+ saml_config = normalize_hash(provider["samlConfig"] || {})
322
+ private_key = saml_config.dig(:sp_metadata, :private_key) || saml_config[:private_key] || saml_config[:sp_private_key]
323
+ raise APIError.new("BAD_REQUEST", message: "SAML Redirect signing requires privateKey") if private_key.to_s.empty?
324
+
325
+ sig_alg = saml_config[:signature_algorithm] ? sso_normalize_saml_signature_algorithm(saml_config[:signature_algorithm]) : XMLSecurity::Document::RSA_SHA256
326
+ signed = query.compact.merge(SigAlg: sig_alg)
327
+ 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? }
328
+ signature_input = URI.encode_www_form(signed_payload)
329
+ signed[:Signature] = Base64.strict_encode64(OpenSSL::PKey.read(private_key).sign(sso_saml_signature_digest(sig_alg), signature_input))
330
+ signed
331
+ end
332
+
333
+ def sso_saml_signature_digest(signature_algorithm)
334
+ case signature_algorithm.to_s
335
+ when /sha512/i
336
+ OpenSSL::Digest.new("SHA512")
337
+ when /sha384/i
338
+ OpenSSL::Digest.new("SHA384")
339
+ when /sha1/i
340
+ OpenSSL::Digest.new("SHA1")
341
+ else
342
+ OpenSSL::Digest.new("SHA256")
343
+ end
344
+ end
345
+
346
+ def sso_saml_post_form(action, saml_param, saml_value, relay_state = nil)
347
+ relay_input = relay_state.to_s.empty? ? "" : "<input type=\"hidden\" name=\"RelayState\" value=\"#{CGI.escapeHTML(relay_state.to_s)}\" />"
348
+ 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>"
349
+ [200, {"content-type" => "text/html"}, [html]]
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,150 @@
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_provider!(ctx, sso_fetch(ctx.params, :provider_id))
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_error = sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
25
+ return in_response_to_error if in_response_to_error
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
+ assertion_id = assertion[:id] || assertion["id"] || assertion[:email]
32
+ replay_key = "#{SSO_SAML_USED_ASSERTION_KEY_PREFIX}#{assertion_id}"
33
+ if ctx.context.internal_adapter.find_verification_value(replay_key)
34
+ raise APIError.new("BAD_REQUEST", message: SSO_ERROR_CODES.fetch("SAML_RESPONSE_REPLAYED"))
35
+ end
36
+ ctx.context.internal_adapter.create_verification_value(identifier: replay_key, value: "used", expiresAt: sso_saml_assertion_replay_expires_at(assertion, config))
37
+
38
+ callback_url = sso_safe_saml_callback_url(ctx, state["callbackURL"] || sso_saml_callback_url(provider) || "/", provider.fetch("providerId"))
39
+ email = (assertion[:email] || assertion["email"]).to_s.downcase
40
+ if config[:disable_implicit_sign_up] && !state["requestSignUp"] && !ctx.context.internal_adapter.find_user_by_email(email)
41
+ return sso_redirect(ctx, sso_append_error(callback_url, "signup disabled"))
42
+ end
43
+
44
+ result = sso_find_or_create_user_result(ctx, provider, assertion, config)
45
+ return sso_redirect(ctx, sso_append_error(callback_url, result.fetch(:error))) if result[:error]
46
+
47
+ user = result.fetch(:user)
48
+ if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
49
+ config[:provision_user].call(user: user, userInfo: assertion, provider: provider)
50
+ end
51
+ session = ctx.context.internal_adapter.create_session(user.fetch("id"))
52
+ sso_store_saml_session(ctx, provider, assertion, session) if config.dig(:saml, :enable_single_logout)
53
+ Cookies.set_session_cookie(ctx, {session: session, user: user})
54
+ sso_redirect(ctx, callback_url)
55
+ end
56
+
57
+ def sso_find_or_create_user(ctx, provider, user_info, config = {})
58
+ sso_find_or_create_user_result(ctx, provider, user_info, config).fetch(:user)
59
+ end
60
+
61
+ def sso_find_or_create_user_result(ctx, provider, user_info, config = {})
62
+ user_info = normalize_hash(user_info)
63
+ email = user_info[:email].to_s.downcase
64
+ account_id = (user_info[:id] || user_info["id"]).to_s
65
+ provider_id = provider.fetch("providerId")
66
+ storage_provider_id = provider["samlConfig"] ? provider_id : "sso:#{provider_id}"
67
+ existing_account = account_id.empty? ? nil : (
68
+ ctx.context.internal_adapter.find_account_by_provider_id(account_id, provider_id) ||
69
+ ctx.context.internal_adapter.find_account_by_provider_id(account_id, "sso:#{provider_id}")
70
+ )
71
+ if existing_account
72
+ user = ctx.context.internal_adapter.find_user_by_id(existing_account.fetch("userId"))
73
+ created = false
74
+ elsif (found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true))
75
+ already_linked_provider = Array(found[:accounts]).any? do |account|
76
+ [provider_id, "sso:#{provider_id}"].include?(account["providerId"])
77
+ end
78
+ if provider["samlConfig"]
79
+ return {error: "account_not_linked"} unless already_linked_provider || sso_saml_trusted_provider?(ctx, provider, email)
80
+ end
81
+
82
+ user = found[:user]
83
+ unless account_id.empty?
84
+ ctx.context.internal_adapter.create_account(
85
+ accountId: account_id,
86
+ providerId: storage_provider_id,
87
+ userId: user.fetch("id")
88
+ )
89
+ end
90
+ oidc_config = normalize_hash(provider["oidcConfig"] || {})
91
+ if oidc_config[:override_user_info] || config[:default_override_user_info]
92
+ update = {}
93
+ update[:name] = user_info[:name] if user_info.key?(:name)
94
+ update[:image] = user_info[:image] if user_info.key?(:image)
95
+ update[:emailVerified] = !!user_info[:email_verified] if user_info.key?(:email_verified)
96
+ user = ctx.context.internal_adapter.update_user(user.fetch("id"), update) if update.any?
97
+ end
98
+ created = false
99
+ else
100
+ created = ctx.context.internal_adapter.create_user(
101
+ email: email,
102
+ name: user_info[:name] || email,
103
+ emailVerified: user_info.key?(:email_verified) ? user_info[:email_verified] : false,
104
+ image: user_info[:image]
105
+ )
106
+ ctx.context.internal_adapter.create_account(
107
+ accountId: account_id.empty? ? created.fetch("id") : account_id,
108
+ providerId: storage_provider_id,
109
+ userId: created.fetch("id")
110
+ )
111
+ user = created
112
+ created = true
113
+ end
114
+ sso_assign_organization_membership(ctx, provider, user, config)
115
+ {user: user, created: created}
116
+ end
117
+
118
+ def sso_saml_trusted_provider?(ctx, provider, email)
119
+ provider_id = provider.fetch("providerId")
120
+ linking = ctx.context.options.account[:account_linking] || {}
121
+ return false if linking[:enabled] == false
122
+
123
+ trusted = Array(linking[:trusted_providers]).map(&:to_s).include?(provider_id.to_s)
124
+ trusted || (provider["domainVerified"] && sso_email_domain_matches?(email, provider["domain"]))
125
+ end
126
+
127
+ def sso_assign_organization_membership(ctx, provider, user, config)
128
+ organization_id = provider["organizationId"]
129
+ return if organization_id.to_s.empty?
130
+ return if config.dig(:organization_provisioning, :disabled)
131
+ return unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
132
+ return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user.fetch("id")}])
133
+
134
+ role = if config.dig(:organization_provisioning, :get_role).respond_to?(:call)
135
+ config.dig(:organization_provisioning, :get_role).call(user: user, userInfo: {}, provider: provider)
136
+ else
137
+ config.dig(:organization_provisioning, :default_role) || config.dig(:organization_provisioning, :role) || "member"
138
+ end
139
+ ctx.context.adapter.create(model: "member", data: {organizationId: organization_id, userId: user.fetch("id"), role: role, createdAt: Time.now})
140
+ end
141
+
142
+ def sso_validate_saml_response!(config, assertion, provider, ctx)
143
+ validator = config.dig(:saml, :validate_response)
144
+ return unless validator.respond_to?(:call)
145
+ return if validator.call(response: assertion, provider: provider, context: ctx)
146
+
147
+ raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
148
+ end
149
+ end
150
+ end