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,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "logger"
5
+ require "onelogin/ruby-saml"
6
+ require "uri"
7
+
8
+ module BetterAuth
9
+ module SSO
10
+ module SAML
11
+ module_function
12
+
13
+ DEFAULT_ATTRIBUTE_MAP = {
14
+ email: %w[email mail emailAddress Email EmailAddress],
15
+ name: %w[name displayName cn Name DisplayName],
16
+ given_name: %w[givenName firstName FirstName],
17
+ family_name: %w[familyName lastName LastName]
18
+ }.freeze
19
+
20
+ def sso_options(**options)
21
+ {
22
+ saml: {
23
+ auth_request_url: auth_request_url(**options),
24
+ parse_response: response_parser(**options)
25
+ }
26
+ }
27
+ end
28
+
29
+ def validate_config_algorithms(config = {}, **options)
30
+ Algorithms.validate_config(config, **options)
31
+ end
32
+
33
+ def validate_saml_algorithms(xml, **options)
34
+ Algorithms.validate(xml, **options)
35
+ end
36
+
37
+ def validate_single_assertion(saml_response)
38
+ Assertions.validate_single_assertion!(saml_response)
39
+ end
40
+
41
+ def auth_request_url(settings: nil, request_options: {}, **_options)
42
+ lambda do |provider:, relay_state:, context:|
43
+ config = BetterAuth::Plugins.normalize_hash(provider["samlConfig"] || provider[:samlConfig] || {})
44
+ saml_settings = settings.respond_to?(:call) ? settings.call(provider: provider, context: context, saml_config: config) : build_settings(provider, context, config, settings)
45
+ OneLogin::RubySaml::Authrequest.new.create(saml_settings, {RelayState: relay_state}.merge(request_options))
46
+ end
47
+ end
48
+
49
+ def response_parser(settings: nil, response_options: {}, attribute_map: DEFAULT_ATTRIBUTE_MAP, **_options)
50
+ lambda do |raw_response:, provider:, context:|
51
+ config = BetterAuth::Plugins.normalize_hash(provider["samlConfig"] || provider[:samlConfig] || {})
52
+ saml_settings = settings.respond_to?(:call) ? settings.call(provider: provider, context: context, saml_config: config) : build_settings(provider, context, config, settings)
53
+ validate_response_xml!(raw_response, config)
54
+ response = OneLogin::RubySaml::Response.new(raw_response, {settings: saml_settings}.merge(response_options))
55
+ unless response.is_valid?
56
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: "Invalid SAML response")
57
+ end
58
+
59
+ attributes = response.attributes
60
+ mapping = BetterAuth::Plugins.normalize_hash(config[:mapping] || {})
61
+ email = mapped_attribute(attributes, mapping[:email]) || first_attribute(attributes, attribute_map.fetch(:email)) || response.nameid
62
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: "Invalid SAML response") if email.to_s.empty?
63
+
64
+ given_name = mapped_attribute(attributes, mapping[:first_name]) || first_attribute(attributes, attribute_map.fetch(:given_name))
65
+ family_name = mapped_attribute(attributes, mapping[:last_name]) || first_attribute(attributes, attribute_map.fetch(:family_name))
66
+ name = [given_name, family_name].compact.join(" ").strip
67
+ name = mapped_attribute(attributes, mapping[:name]) || first_attribute(attributes, attribute_map.fetch(:name)) if name.empty?
68
+ extra_fields = mapped_extra_fields(attributes, mapping)
69
+ email_verified = mapping[:email_verified] ? mapped_attribute(attributes, mapping[:email_verified]) : false
70
+ extra_fields.merge(
71
+ email: email.to_s.downcase,
72
+ name: name.to_s.empty? ? email.to_s : name.to_s,
73
+ id: mapped_attribute(attributes, mapping[:id]) || assertion_identifier(response, email),
74
+ name_id: response.nameid,
75
+ session_index: response.sessionindex,
76
+ email_verified: (email_verified == false) ? false : !email_verified.to_s.empty?
77
+ )
78
+ end
79
+ end
80
+
81
+ def build_settings(provider, context, config, overrides = nil)
82
+ settings = overrides || OneLogin::RubySaml::Settings.new
83
+ provider_id = provider.fetch("providerId")
84
+ base_url = context.context.base_url
85
+ idp_metadata = BetterAuth::Plugins.respond_to?(:sso_saml_idp_metadata) ? BetterAuth::Plugins.sso_saml_idp_metadata(config) : {}
86
+ sso_service = BetterAuth::Plugins.respond_to?(:sso_saml_preferred_service) ? BetterAuth::Plugins.sso_saml_preferred_service(idp_metadata[:single_sign_on_service]) : nil
87
+ sso_service = BetterAuth::Plugins.normalize_hash(sso_service || {})
88
+ settings.assertion_consumer_service_url = if BetterAuth::Plugins.respond_to?(:sso_saml_acs_url?) && BetterAuth::Plugins.sso_saml_acs_url?(config[:callback_url])
89
+ config[:callback_url]
90
+ else
91
+ "#{base_url}/sso/saml2/sp/acs/#{URI.encode_www_form_component(provider_id)}"
92
+ end
93
+ settings.sp_entity_id = config.dig(:sp_metadata, :entity_id) || config[:audience] || "#{base_url}/sso/saml2/sp/metadata?providerId=#{URI.encode_www_form_component(provider_id)}"
94
+ settings.idp_entity_id = idp_metadata[:entity_id] || provider["issuer"] || provider[:issuer]
95
+ settings.idp_sso_service_url = config[:entry_point] || sso_service[:location]
96
+ settings.idp_cert = config[:cert] || idp_metadata[:cert] unless (config[:cert] || idp_metadata[:cert]).to_s.empty?
97
+ settings.name_identifier_format = config[:identifier_format] unless config[:identifier_format].to_s.empty?
98
+ private_key = config.dig(:sp_metadata, :private_key) || config[:private_key] || config[:sp_private_key]
99
+ authn_requests_signed = config.fetch(:authn_requests_signed, config[:want_authn_requests_signed])
100
+ if authn_requests_signed && private_key.to_s.empty?
101
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: "SAML authnRequestsSigned requires privateKey")
102
+ end
103
+ settings.private_key = private_key unless private_key.to_s.empty?
104
+ certificate = config.dig(:sp_metadata, :certificate) || config[:sp_certificate]
105
+ certificate ||= config[:certificate] if config[:certificate].is_a?(String)
106
+ settings.certificate = certificate unless certificate.to_s.empty?
107
+ settings.security[:want_assertions_signed] = config.fetch(:want_assertions_signed, true)
108
+ settings.security[:want_messages_signed] = config.fetch(:want_messages_signed, false)
109
+ settings.security[:want_assertions_encrypted] = config.fetch(:want_assertions_encrypted, false)
110
+ settings.security[:authn_requests_signed] = !!authn_requests_signed
111
+ settings.security[:strict_audience_validation] = true
112
+ settings.security[:digest_method] = config[:digest_algorithm] || XMLSecurity::Document::SHA256
113
+ settings.security[:signature_method] = if config[:signature_algorithm]
114
+ BetterAuth::Plugins.sso_normalize_saml_signature_algorithm(config[:signature_algorithm])
115
+ else
116
+ XMLSecurity::Document::RSA_SHA256
117
+ end
118
+ settings
119
+ end
120
+
121
+ def validate_response_xml!(raw_response, config)
122
+ BetterAuth::Plugins.sso_validate_single_saml_assertion!(raw_response)
123
+ xml = Base64.decode64(raw_response.to_s)
124
+ BetterAuth::Plugins.sso_validate_saml_algorithms!(
125
+ xml,
126
+ on_deprecated: config.fetch(:on_deprecated_algorithm, "reject"),
127
+ allowed_signature_algorithms: config[:allowed_signature_algorithms],
128
+ allowed_digest_algorithms: config[:allowed_digest_algorithms],
129
+ allowed_key_encryption_algorithms: config[:allowed_key_encryption_algorithms],
130
+ allowed_data_encryption_algorithms: config[:allowed_data_encryption_algorithms]
131
+ )
132
+ rescue BetterAuth::APIError
133
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: "Invalid SAML response")
134
+ rescue
135
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: "Invalid SAML response")
136
+ end
137
+
138
+ def first_attribute(attributes, names)
139
+ Array(names).each do |name|
140
+ value = attribute_value(attributes, name)
141
+ value = value.first if value.is_a?(Array)
142
+ return value unless value.to_s.empty?
143
+ end
144
+ nil
145
+ end
146
+
147
+ def mapped_attribute(attributes, name)
148
+ return nil if name.to_s.empty?
149
+
150
+ value = attribute_value(attributes, name)
151
+ value = value.first if value.is_a?(Array)
152
+ value unless value.to_s.empty?
153
+ end
154
+
155
+ def mapped_extra_fields(attributes, mapping)
156
+ BetterAuth::Plugins.normalize_hash(mapping[:extra_fields] || {}).each_with_object({}) do |(target, source), result|
157
+ result[target] = mapped_attribute(attributes, source)
158
+ end
159
+ end
160
+
161
+ def attribute_value(attributes, name)
162
+ [name, name.to_s, BetterAuth::Plugins.normalize_key(name)].each do |key|
163
+ return attributes[key] if attributes.respond_to?(:key?) && attributes.key?(key)
164
+ end
165
+ attributes[name] || attributes[name.to_s] || attributes[BetterAuth::Plugins.normalize_key(name)]
166
+ end
167
+
168
+ def assertion_identifier(response, email)
169
+ response.assertion_id || response.nameid || response.sessionindex || email
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ module SAMLHooks
6
+ module_function
7
+
8
+ def merge_options(sso_options = {}, saml_options = {})
9
+ sso_options = BetterAuth::Plugins.normalize_hash(sso_options || {})
10
+ saml_options = BetterAuth::Plugins.normalize_hash(saml_options || {})
11
+ sso_options.merge(saml_options) do |key, old_value, new_value|
12
+ if key == :saml
13
+ BetterAuth::Plugins.normalize_hash(old_value || {}).merge(BetterAuth::Plugins.normalize_hash(new_value || {}))
14
+ else
15
+ new_value
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ module SAMLState
6
+ module_function
7
+
8
+ def generate_relay_state(ctx, link = nil, additional_data = {})
9
+ callback_url = BetterAuth::Plugins.sso_fetch(ctx.body, :callback_url)
10
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: "callbackURL is required") if callback_url.to_s.empty?
11
+
12
+ extra = (additional_data == false) ? {} : (additional_data || {})
13
+ BetterAuth::Plugins.sso_generate_saml_relay_state(
14
+ ctx,
15
+ extra.merge(
16
+ callbackURL: callback_url,
17
+ errorURL: BetterAuth::Plugins.sso_fetch(ctx.body, :error_callback_url),
18
+ newUserURL: BetterAuth::Plugins.sso_fetch(ctx.body, :new_user_callback_url),
19
+ requestSignUp: BetterAuth::Plugins.sso_fetch(ctx.body, :request_sign_up),
20
+ link: link
21
+ )
22
+ )
23
+ end
24
+
25
+ def parse_relay_state(ctx)
26
+ BetterAuth::Plugins.sso_parse_saml_relay_state(ctx, BetterAuth::Plugins.sso_fetch(ctx.body, :relay_state))
27
+ end
28
+ end
29
+ end
30
+ end
metadata ADDED
@@ -0,0 +1,195 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: better_auth-saml
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.10.0
5
+ platform: ruby
6
+ authors:
7
+ - Sebastian Sala
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: better_auth
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: base64
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '1.0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0.2'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: logger
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '1.6'
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '2.0'
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '1.6'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '2.0'
66
+ - !ruby/object:Gem::Dependency
67
+ name: ruby-saml
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '1.18'
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 1.18.1
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.18'
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: 1.18.1
86
+ - !ruby/object:Gem::Dependency
87
+ name: bundler
88
+ requirement: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - "~>"
91
+ - !ruby/object:Gem::Version
92
+ version: '2.5'
93
+ type: :development
94
+ prerelease: false
95
+ version_requirements: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - "~>"
98
+ - !ruby/object:Gem::Version
99
+ version: '2.5'
100
+ - !ruby/object:Gem::Dependency
101
+ name: minitest
102
+ requirement: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - "~>"
105
+ - !ruby/object:Gem::Version
106
+ version: '5.25'
107
+ type: :development
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - "~>"
112
+ - !ruby/object:Gem::Version
113
+ version: '5.25'
114
+ - !ruby/object:Gem::Dependency
115
+ name: rake
116
+ requirement: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - "~>"
119
+ - !ruby/object:Gem::Version
120
+ version: '13.2'
121
+ type: :development
122
+ prerelease: false
123
+ version_requirements: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - "~>"
126
+ - !ruby/object:Gem::Version
127
+ version: '13.2'
128
+ - !ruby/object:Gem::Dependency
129
+ name: standardrb
130
+ requirement: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - "~>"
133
+ - !ruby/object:Gem::Version
134
+ version: '1.0'
135
+ type: :development
136
+ prerelease: false
137
+ version_requirements: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - "~>"
140
+ - !ruby/object:Gem::Version
141
+ version: '1.0'
142
+ description: SAML 2.0 service provider primitives and plugin extensions for Better
143
+ Auth Ruby enterprise SSO. Pair with better_auth-sso for provider management or require
144
+ directly for SAML-only integrations.
145
+ email:
146
+ - sebastian.sala.tech@gmail.com
147
+ executables: []
148
+ extensions: []
149
+ extra_rdoc_files: []
150
+ files:
151
+ - CHANGELOG.md
152
+ - README.md
153
+ - lib/better_auth/plugins/saml.rb
154
+ - lib/better_auth/saml.rb
155
+ - lib/better_auth/saml/version.rb
156
+ - lib/better_auth/sso/constants.rb
157
+ - lib/better_auth/sso/plugin/saml_core.rb
158
+ - lib/better_auth/sso/plugin/saml_metadata_and_logout.rb
159
+ - lib/better_auth/sso/plugin/saml_response.rb
160
+ - lib/better_auth/sso/plugin/saml_validation_and_state.rb
161
+ - lib/better_auth/sso/routes/saml_pipeline.rb
162
+ - lib/better_auth/sso/saml.rb
163
+ - lib/better_auth/sso/saml/algorithms.rb
164
+ - lib/better_auth/sso/saml/assertions.rb
165
+ - lib/better_auth/sso/saml/error_codes.rb
166
+ - lib/better_auth/sso/saml/parser.rb
167
+ - lib/better_auth/sso/saml/timestamp.rb
168
+ - lib/better_auth/sso/saml_hooks.rb
169
+ - lib/better_auth/sso/saml_state.rb
170
+ homepage: https://github.com/sebasxsala/better-auth-rb
171
+ licenses:
172
+ - MIT
173
+ metadata:
174
+ homepage_uri: https://github.com/sebasxsala/better-auth-rb
175
+ source_code_uri: https://github.com/sebasxsala/better-auth-rb
176
+ changelog_uri: https://github.com/sebasxsala/better-auth-rb/blob/main/packages/better_auth-saml/CHANGELOG.md
177
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth-rb/issues
178
+ rdoc_options: []
179
+ require_paths:
180
+ - lib
181
+ required_ruby_version: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - ">="
184
+ - !ruby/object:Gem::Version
185
+ version: 3.2.0
186
+ required_rubygems_version: !ruby/object:Gem::Requirement
187
+ requirements:
188
+ - - ">="
189
+ - !ruby/object:Gem::Version
190
+ version: '0'
191
+ requirements: []
192
+ rubygems_version: 3.6.9
193
+ specification_version: 4
194
+ summary: SAML 2.0 SP support for Better Auth Ruby SSO
195
+ test_files: []