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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/README.md +34 -0
- data/lib/better_auth/plugins/saml.rb +5 -0
- data/lib/better_auth/saml/version.rb +7 -0
- data/lib/better_auth/saml.rb +16 -0
- data/lib/better_auth/sso/constants.rb +20 -0
- data/lib/better_auth/sso/plugin/saml_core.rb +54 -0
- data/lib/better_auth/sso/plugin/saml_metadata_and_logout.rb +378 -0
- data/lib/better_auth/sso/plugin/saml_response.rb +167 -0
- data/lib/better_auth/sso/plugin/saml_validation_and_state.rb +183 -0
- data/lib/better_auth/sso/routes/saml_pipeline.rb +19 -0
- data/lib/better_auth/sso/saml/algorithms.rb +96 -0
- data/lib/better_auth/sso/saml/assertions.rb +21 -0
- data/lib/better_auth/sso/saml/error_codes.rb +24 -0
- data/lib/better_auth/sso/saml/parser.rb +19 -0
- data/lib/better_auth/sso/saml/timestamp.rb +19 -0
- data/lib/better_auth/sso/saml.rb +173 -0
- data/lib/better_auth/sso/saml_hooks.rb +21 -0
- data/lib/better_auth/sso/saml_state.rb +30 -0
- metadata +195 -0
|
@@ -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
|