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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +36 -2
- data/lib/better_auth/plugins/sso.rb +10 -1766
- data/lib/better_auth/sso/linking/org_assignment.rb +0 -3
- data/lib/better_auth/sso/plugin/core.rb +139 -0
- data/lib/better_auth/sso/plugin/endpoints.rb +151 -0
- data/lib/better_auth/sso/plugin/oidc_discovery.rb +75 -0
- data/lib/better_auth/sso/plugin/oidc_runtime.rb +420 -0
- data/lib/better_auth/sso/plugin/provider_utils.rb +216 -0
- data/lib/better_auth/sso/plugin/providers.rb +131 -0
- data/lib/better_auth/sso/plugin/saml_metadata_and_logout.rb +352 -0
- data/lib/better_auth/sso/plugin/saml_response.rb +150 -0
- data/lib/better_auth/sso/plugin/saml_validation_and_state.rb +183 -0
- data/lib/better_auth/sso/plugin/sign_in_and_oidc_callbacks.rb +125 -0
- data/lib/better_auth/sso/routes/schemas.rb +14 -8
- data/lib/better_auth/sso/routes/sso.rb +1 -1
- data/lib/better_auth/sso/saml_state.rb +1 -1
- data/lib/better_auth/sso/version.rb +1 -1
- metadata +27 -2
|
@@ -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
|