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,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,125 @@
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") 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
+ state = BetterAuth::Crypto.sign_jwt(
33
+ state_data.merge({nonce: BetterAuth::Crypto.random_string(32)}).merge(sso_oidc_pkce_state(provider)),
34
+ ctx.context.secret,
35
+ expires_in: 600
36
+ )
37
+ url = sso_oidc_authorization_url(provider, ctx, state, config, body)
38
+ elsif provider["samlConfig"]
39
+ relay_state = sso_generate_saml_relay_state(ctx, state_data)
40
+ url = sso_saml_authorization_url(provider, relay_state, ctx, config)
41
+ sso_store_saml_authn_request(ctx, provider, url, config)
42
+ else
43
+ raise APIError.new("BAD_REQUEST", message: "OIDC provider is not configured")
44
+ end
45
+ ctx.json({url: url, redirect: true})
46
+ end
47
+ end
48
+
49
+ def sso_oidc_callback_endpoint(config = {})
50
+ Endpoint.new(path: "/sso/callback/:providerId", method: "GET") do |ctx|
51
+ sso_handle_oidc_callback(ctx, config, sso_fetch(ctx.params, :provider_id))
52
+ end
53
+ end
54
+
55
+ def sso_oidc_shared_callback_endpoint(config = {})
56
+ Endpoint.new(path: "/sso/callback", method: "GET") do |ctx|
57
+ state = sso_verify_state(ctx.query[:state] || ctx.query["state"], ctx.context.secret)
58
+ next ctx.redirect("#{ctx.context.base_url}/error?error=invalid_state") unless state
59
+
60
+ sso_handle_oidc_callback(ctx, config, state["providerId"], state: state)
61
+ end
62
+ end
63
+
64
+ def sso_handle_oidc_callback(ctx, config, provider_id, state: nil)
65
+ state ||= sso_verify_state(ctx.query[:state] || ctx.query["state"], ctx.context.secret)
66
+ return ctx.redirect("#{ctx.context.base_url}/error?error=invalid_state") unless state
67
+
68
+ callback_url = state["callbackURL"] || "/"
69
+ error_url = state["errorURL"] || callback_url
70
+ if ctx.query[:error] || ctx.query["error"]
71
+ error = ctx.query[:error] || ctx.query["error"]
72
+ description = ctx.query[:error_description] || ctx.query["error_description"]
73
+ return sso_redirect(ctx, sso_append_error(error_url, error, description))
74
+ end
75
+ state_provider_id = state["providerId"] || state[:providerId]
76
+ if state_provider_id.to_s != provider_id.to_s
77
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_state", "provider mismatch"))
78
+ end
79
+
80
+ provider = sso_callback_provider(ctx, config, provider_id)
81
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "provider not found")) unless provider
82
+ if config.dig(:domain_verification, :enabled) && !(provider.key?("domainVerified") && provider["domainVerified"])
83
+ raise APIError.new("UNAUTHORIZED", message: "Provider domain has not been verified")
84
+ end
85
+
86
+ provider = sso_ensure_runtime_oidc_provider(ctx, provider, config)
87
+ oidc_config = normalize_hash(provider["oidcConfig"] || {})
88
+ oidc_config[:issuer] ||= provider["issuer"]
89
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "provider not found")) if oidc_config.empty?
90
+
91
+ tokens = sso_oidc_tokens(ctx, provider, oidc_config, state, config)
92
+ unless tokens
93
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "token_response_not_found"))
94
+ end
95
+ if oidc_config[:user_info_endpoint].to_s.empty? && tokens[:id_token] && oidc_config[:jwks_endpoint].to_s.empty?
96
+ begin
97
+ provider = sso_ensure_runtime_oidc_provider(ctx, provider, config, require_jwks: true)
98
+ oidc_config = normalize_hash(provider["oidcConfig"] || {})
99
+ oidc_config[:issuer] ||= provider["issuer"]
100
+ rescue APIError
101
+ # Fall through to the upstream callback error when JWKS is still unavailable.
102
+ end
103
+ end
104
+ user_info = sso_oidc_user_info(ctx, oidc_config, tokens, config, expected_nonce: state["nonce"] || state[:nonce])
105
+ if user_info[:_sso_error]
106
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", user_info[:_sso_error]))
107
+ end
108
+ if user_info[:email].to_s.empty? || user_info[:id].to_s.empty?
109
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "missing_user_info"))
110
+ end
111
+ if config[:disable_implicit_sign_up] && !state["requestSignUp"] && !ctx.context.internal_adapter.find_user_by_email(user_info[:email].to_s.downcase)
112
+ return sso_redirect(ctx, sso_append_error(error_url, "signup disabled"))
113
+ end
114
+
115
+ result = sso_find_or_create_user_result(ctx, provider, user_info, config)
116
+ if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
117
+ config[:provision_user].call(user: result.fetch(:user), userInfo: user_info, token: tokens, provider: provider)
118
+ end
119
+ session = ctx.context.internal_adapter.create_session(result.fetch(:user).fetch("id"))
120
+ Cookies.set_session_cookie(ctx, {session: session, user: result.fetch(:user)})
121
+ redirect_to = (result.fetch(:created) && state["newUserURL"].to_s != "") ? state["newUserURL"] : callback_url
122
+ sso_redirect(ctx, redirect_to || "/")
123
+ end
124
+ end
125
+ end
@@ -44,17 +44,18 @@ module BetterAuth
44
44
 
45
45
  def plugin_schema(config = {})
46
46
  normalized = BetterAuth::Plugins.normalize_hash(config || {})
47
+ field_mappings = BetterAuth::Plugins.normalize_hash(normalized[:fields] || {})
47
48
  fields = {
48
- issuer: {type: "string", required: true},
49
- oidcConfig: {type: "string", required: false},
50
- samlConfig: {type: "string", required: false},
51
- userId: {type: "string", required: true},
52
- providerId: {type: "string", required: true, unique: true},
53
- domain: {type: "string", required: true},
54
- organizationId: {type: "string", required: false}
49
+ issuer: field("issuer", {type: "string", required: true}, field_mappings),
50
+ oidcConfig: field("oidcConfig", {type: "string", required: false}, field_mappings),
51
+ samlConfig: field("samlConfig", {type: "string", required: false}, field_mappings),
52
+ userId: field("userId", {type: "string", required: true, references: {model: "user", field: "id"}}, field_mappings),
53
+ providerId: field("providerId", {type: "string", required: true, unique: true}, field_mappings),
54
+ domain: field("domain", {type: "string", required: true}, field_mappings),
55
+ organizationId: field("organizationId", {type: "string", required: false}, field_mappings)
55
56
  }
56
57
  if normalized.dig(:domain_verification, :enabled)
57
- fields[:domainVerified] = {type: "boolean", required: false, default_value: false}
58
+ fields[:domainVerified] = field("domainVerified", {type: "boolean", required: false, default_value: false}, field_mappings)
58
59
  end
59
60
  {
60
61
  ssoProvider: {
@@ -71,6 +72,11 @@ module BetterAuth
71
72
  def saml_config_key?(key)
72
73
  SAML_CONFIG_KEYS.include?(BetterAuth::Plugins.normalize_key(key))
73
74
  end
75
+
76
+ def field(logical_name, attributes, mappings)
77
+ mapping = mappings[BetterAuth::Plugins.normalize_key(logical_name)]
78
+ mapping ? attributes.merge(field_name: mapping) : attributes
79
+ end
74
80
  end
75
81
  end
76
82
  end
@@ -20,7 +20,7 @@ module BetterAuth
20
20
  initiate_slo: BetterAuth::Plugins.sso_initiate_slo_endpoint(normalized),
21
21
  list_sso_providers: BetterAuth::Plugins.sso_list_providers_endpoint,
22
22
  get_sso_provider: BetterAuth::Plugins.sso_get_provider_endpoint,
23
- update_sso_provider: BetterAuth::Plugins.sso_update_provider_endpoint,
23
+ update_sso_provider: BetterAuth::Plugins.sso_update_provider_endpoint(normalized),
24
24
  delete_sso_provider: BetterAuth::Plugins.sso_delete_provider_endpoint
25
25
  }
26
26
  if normalized.dig(:domain_verification, :enabled)
@@ -7,7 +7,7 @@ module BetterAuth
7
7
 
8
8
  def generate_relay_state(ctx, link = nil, additional_data = {})
9
9
  callback_url = BetterAuth::Plugins.sso_fetch(ctx.body, :callback_url)
10
- raise APIError.new("BAD_REQUEST", message: "callbackURL is required") if callback_url.to_s.empty?
10
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: "callbackURL is required") if callback_url.to_s.empty?
11
11
 
12
12
  extra = (additional_data == false) ? {} : (additional_data || {})
13
13
  BetterAuth::Plugins.sso_generate_saml_relay_state(
@@ -2,7 +2,7 @@
2
2
 
3
3
  module BetterAuth
4
4
  module SSO
5
- VERSION = "0.6.2"
5
+ VERSION = "0.8.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.6.2
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala
@@ -43,6 +43,20 @@ dependencies:
43
43
  - - "<"
44
44
  - !ruby/object:Gem::Version
45
45
  version: '1.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: jwt
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '2.8'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '2.8'
46
60
  - !ruby/object:Gem::Dependency
47
61
  name: logger
48
62
  requirement: !ruby/object:Gem::Requirement
@@ -140,7 +154,8 @@ dependencies:
140
154
  - !ruby/object:Gem::Version
141
155
  version: '1.0'
142
156
  description: Adds SSO provider management, OIDC SSO, and SAML SSO integration for
143
- Better Auth Ruby.
157
+ Better Auth Ruby. Better Auth Ruby is an independent modern authentication framework
158
+ for Ruby inspired by Better Auth.
144
159
  email:
145
160
  - sebastian.sala.tech@gmail.com
146
161
  executables: []
@@ -161,6 +176,16 @@ files:
161
176
  - lib/better_auth/sso/oidc/discovery.rb
162
177
  - lib/better_auth/sso/oidc/errors.rb
163
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
164
189
  - lib/better_auth/sso/routes/domain_verification.rb
165
190
  - lib/better_auth/sso/routes/helpers.rb
166
191
  - lib/better_auth/sso/routes/providers.rb