better_auth-sso 0.7.0 → 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,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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def sso_sign_in_endpoint(config = {})
8
+ Endpoint.new(path: "/sign-in/sso", method: "POST", metadata: sso_openapi_for(:sign_in)) do |ctx|
9
+ body = normalize_hash(ctx.body)
10
+ provider = sso_select_provider(ctx, body, config)
11
+ provider_type = body[:provider_type].to_s
12
+ if provider_type == "oidc" && !provider["oidcConfig"]
13
+ raise APIError.new("BAD_REQUEST", message: "OIDC provider is not configured")
14
+ end
15
+ if provider_type == "saml" && !provider["samlConfig"]
16
+ raise APIError.new("BAD_REQUEST", message: "SAML provider is not configured")
17
+ end
18
+ if config.dig(:domain_verification, :enabled) && !(provider.key?("domainVerified") && provider["domainVerified"])
19
+ raise APIError.new("UNAUTHORIZED", message: "Provider domain has not been verified")
20
+ end
21
+
22
+ state_data = {
23
+ providerId: provider.fetch("providerId"),
24
+ callbackURL: body[:callback_url] || "/",
25
+ errorURL: body[:error_callback_url],
26
+ newUserURL: body[:new_user_callback_url],
27
+ requestSignUp: body[:request_sign_up]
28
+ }
29
+
30
+ if provider["oidcConfig"] && provider_type != "saml"
31
+ provider = sso_ensure_runtime_oidc_provider(ctx, provider, config)
32
+ pkce = sso_oidc_pkce_state(provider)
33
+ state = BetterAuth::Crypto.sign_jwt(
34
+ state_data.merge({nonce: BetterAuth::Crypto.random_string(32)}).merge(pkce.except(:codeVerifier)),
35
+ ctx.context.secret,
36
+ expires_in: 600
37
+ )
38
+ sso_store_oidc_pkce_verifier(ctx, state, pkce[:codeVerifier]) if pkce[:codeVerifier]
39
+ url = sso_oidc_authorization_url(provider, ctx, state, config, body)
40
+ elsif provider["samlConfig"]
41
+ relay_state = sso_generate_saml_relay_state(ctx, state_data)
42
+ url = sso_saml_authorization_url(provider, relay_state, ctx, config)
43
+ sso_store_saml_authn_request(ctx, provider, url, config)
44
+ else
45
+ raise APIError.new("BAD_REQUEST", message: "OIDC provider is not configured")
46
+ end
47
+ ctx.json({url: url, redirect: true})
48
+ end
49
+ end
50
+
51
+ def sso_oidc_callback_endpoint(config = {})
52
+ Endpoint.new(path: "/sso/callback/:providerId", method: "GET") do |ctx|
53
+ sso_handle_oidc_callback(ctx, config, sso_fetch(ctx.params, :provider_id))
54
+ end
55
+ end
56
+
57
+ def sso_oidc_shared_callback_endpoint(config = {})
58
+ Endpoint.new(path: "/sso/callback", method: "GET") do |ctx|
59
+ state = sso_verify_state(ctx.query[:state] || ctx.query["state"], ctx.context.secret)
60
+ next ctx.redirect("#{ctx.context.base_url}/error?error=invalid_state") unless state
61
+
62
+ sso_handle_oidc_callback(ctx, config, state["providerId"], state: state)
63
+ end
64
+ end
65
+
66
+ def sso_handle_oidc_callback(ctx, config, provider_id, state: nil)
67
+ state ||= sso_verify_state(ctx.query[:state] || ctx.query["state"], ctx.context.secret)
68
+ return ctx.redirect("#{ctx.context.base_url}/error?error=invalid_state") unless state
69
+
70
+ callback_url = sso_safe_oidc_redirect_url(ctx, state["callbackURL"] || "/")
71
+ error_url = sso_safe_oidc_redirect_url(ctx, state["errorURL"] || callback_url)
72
+ if ctx.query[:error] || ctx.query["error"]
73
+ error = ctx.query[:error] || ctx.query["error"]
74
+ description = ctx.query[:error_description] || ctx.query["error_description"]
75
+ return sso_redirect(ctx, sso_append_error(error_url, error, description))
76
+ end
77
+ state_provider_id = state["providerId"] || state[:providerId]
78
+ if state_provider_id.to_s != provider_id.to_s
79
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_state", "provider mismatch"))
80
+ end
81
+
82
+ provider = sso_callback_provider(ctx, config, provider_id)
83
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "provider not found")) unless provider
84
+ if config.dig(:domain_verification, :enabled) && !(provider.key?("domainVerified") && provider["domainVerified"])
85
+ raise APIError.new("UNAUTHORIZED", message: "Provider domain has not been verified")
86
+ end
87
+
88
+ provider = sso_ensure_runtime_oidc_provider(ctx, provider, config)
89
+ oidc_config = sso_provider_config_hash(provider["oidcConfig"])
90
+ oidc_config[:issuer] ||= provider["issuer"]
91
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "provider not found")) if oidc_config.empty?
92
+
93
+ raw_state = ctx.query[:state] || ctx.query["state"]
94
+ tokens = sso_oidc_tokens(ctx, provider, oidc_config, state, config, raw_state: raw_state)
95
+ unless tokens
96
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "token_response_not_found"))
97
+ end
98
+ if oidc_config[:user_info_endpoint].to_s.empty? && tokens[:id_token] && oidc_config[:jwks_endpoint].to_s.empty?
99
+ begin
100
+ provider = sso_ensure_runtime_oidc_provider(ctx, provider, config, require_jwks: true)
101
+ oidc_config = sso_provider_config_hash(provider["oidcConfig"])
102
+ oidc_config[:issuer] ||= provider["issuer"]
103
+ rescue APIError
104
+ # Fall through to the upstream callback error when JWKS is still unavailable.
105
+ end
106
+ end
107
+ user_info = sso_oidc_user_info(ctx, oidc_config, tokens, config, expected_nonce: state["nonce"] || state[:nonce])
108
+ if user_info[:_sso_error]
109
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", user_info[:_sso_error]))
110
+ end
111
+ if user_info[:email].to_s.empty? || user_info[:id].to_s.empty?
112
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "missing_user_info"))
113
+ end
114
+ if config[:disable_implicit_sign_up] && !state["requestSignUp"] && !ctx.context.internal_adapter.find_user_by_email(user_info[:email].to_s.downcase)
115
+ return sso_redirect(ctx, sso_append_error(error_url, "signup disabled"))
116
+ end
117
+
118
+ result = sso_find_or_create_user_result(ctx, provider, user_info, config)
119
+ return sso_redirect(ctx, sso_append_error(callback_url, result.fetch(:error))) if result[:error]
120
+
121
+ if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
122
+ config[:provision_user].call(user: result.fetch(:user), userInfo: user_info, token: tokens, provider: provider)
123
+ end
124
+ session = ctx.context.internal_adapter.create_session(result.fetch(:user).fetch("id"))
125
+ Cookies.set_session_cookie(ctx, {session: session, user: result.fetch(:user)})
126
+ redirect_to = (result.fetch(:created) && state["newUserURL"].to_s != "") ? sso_safe_oidc_redirect_url(ctx, state["newUserURL"]) : callback_url
127
+ sso_redirect(ctx, redirect_to || "/")
128
+ end
129
+ end
130
+ end
@@ -59,7 +59,7 @@ module BetterAuth
59
59
  end
60
60
  {
61
61
  ssoProvider: {
62
- model_name: normalized[:model_name] || "ssoProviders",
62
+ model_name: normalized[:model_name] || "sso_providers",
63
63
  fields: fields
64
64
  }
65
65
  }
@@ -66,7 +66,7 @@ module BetterAuth
66
66
  name = [given_name, family_name].compact.join(" ").strip
67
67
  name = mapped_attribute(attributes, mapping[:name]) || first_attribute(attributes, attribute_map.fetch(:name)) if name.empty?
68
68
  extra_fields = mapped_extra_fields(attributes, mapping)
69
- email_verified = mapping[:email_verified] ? mapped_attribute(attributes, mapping[:email_verified]) : true
69
+ email_verified = mapping[:email_verified] ? mapped_attribute(attributes, mapping[:email_verified]) : false
70
70
  extra_fields.merge(
71
71
  email: email.to_s.downcase,
72
72
  name: name.to_s.empty? ? email.to_s : name.to_s,
@@ -2,7 +2,7 @@
2
2
 
3
3
  module BetterAuth
4
4
  module SSO
5
- VERSION = "0.7.0"
5
+ VERSION = "0.10.0"
6
6
  PACKAGE_VERSION = VERSION
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth-sso
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala
@@ -154,7 +154,8 @@ dependencies:
154
154
  - !ruby/object:Gem::Version
155
155
  version: '1.0'
156
156
  description: Adds SSO provider management, OIDC SSO, and SAML SSO integration for
157
- Better Auth Ruby.
157
+ Better Auth Ruby. Better Auth Ruby is an independent modern authentication framework
158
+ for Ruby inspired by Better Auth.
158
159
  email:
159
160
  - sebastian.sala.tech@gmail.com
160
161
  executables: []
@@ -175,6 +176,16 @@ files:
175
176
  - lib/better_auth/sso/oidc/discovery.rb
176
177
  - lib/better_auth/sso/oidc/errors.rb
177
178
  - lib/better_auth/sso/oidc/types.rb
179
+ - lib/better_auth/sso/plugin/core.rb
180
+ - lib/better_auth/sso/plugin/endpoints.rb
181
+ - lib/better_auth/sso/plugin/oidc_discovery.rb
182
+ - lib/better_auth/sso/plugin/oidc_runtime.rb
183
+ - lib/better_auth/sso/plugin/provider_utils.rb
184
+ - lib/better_auth/sso/plugin/providers.rb
185
+ - lib/better_auth/sso/plugin/saml_metadata_and_logout.rb
186
+ - lib/better_auth/sso/plugin/saml_response.rb
187
+ - lib/better_auth/sso/plugin/saml_validation_and_state.rb
188
+ - lib/better_auth/sso/plugin/sign_in_and_oidc_callbacks.rb
178
189
  - lib/better_auth/sso/routes/domain_verification.rb
179
190
  - lib/better_auth/sso/routes/helpers.rb
180
191
  - lib/better_auth/sso/routes/providers.rb
@@ -192,14 +203,14 @@ files:
192
203
  - lib/better_auth/sso/types.rb
193
204
  - lib/better_auth/sso/utils.rb
194
205
  - lib/better_auth/sso/version.rb
195
- homepage: https://github.com/sebasxsala/better-auth
206
+ homepage: https://github.com/sebasxsala/better-auth-rb
196
207
  licenses:
197
208
  - MIT
198
209
  metadata:
199
- homepage_uri: https://github.com/sebasxsala/better-auth
200
- source_code_uri: https://github.com/sebasxsala/better-auth
201
- changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth-sso/CHANGELOG.md
202
- bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
210
+ homepage_uri: https://github.com/sebasxsala/better-auth-rb
211
+ source_code_uri: https://github.com/sebasxsala/better-auth-rb
212
+ changelog_uri: https://github.com/sebasxsala/better-auth-rb/blob/main/packages/better_auth-sso/CHANGELOG.md
213
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth-rb/issues
203
214
  rdoc_options: []
204
215
  require_paths:
205
216
  - lib