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
|
@@ -13,1769 +13,13 @@ require "time"
|
|
|
13
13
|
require "uri"
|
|
14
14
|
require "zlib"
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"SAML_RESPONSE_REPLAYED" => "SAML response has already been used",
|
|
27
|
-
"SINGLE_LOGOUT_NOT_ENABLED" => "Single Logout is not enabled",
|
|
28
|
-
"INVALID_LOGOUT_REQUEST" => "Invalid LogoutRequest",
|
|
29
|
-
"INVALID_LOGOUT_RESPONSE" => "Invalid LogoutResponse",
|
|
30
|
-
"LOGOUT_FAILED_AT_IDP" => "Logout failed at IdP",
|
|
31
|
-
"IDP_SLO_NOT_SUPPORTED" => "IdP does not support Single Logout Service"
|
|
32
|
-
}.freeze
|
|
33
|
-
|
|
34
|
-
SSO_SAML_SIGNATURE_ALGORITHMS = {
|
|
35
|
-
"rsa-sha1" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
36
|
-
"rsa-sha256" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
37
|
-
"rsa-sha384" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
38
|
-
"rsa-sha512" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
|
|
39
|
-
"ecdsa-sha256" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
|
|
40
|
-
"ecdsa-sha384" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
|
|
41
|
-
"ecdsa-sha512" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512",
|
|
42
|
-
"sha1" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
43
|
-
"sha256" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
44
|
-
"sha384" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
|
|
45
|
-
"sha512" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"
|
|
46
|
-
}.freeze
|
|
47
|
-
|
|
48
|
-
SSO_SAML_DIGEST_ALGORITHMS = {
|
|
49
|
-
"sha1" => "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
50
|
-
"sha256" => "http://www.w3.org/2001/04/xmlenc#sha256",
|
|
51
|
-
"sha384" => "http://www.w3.org/2001/04/xmldsig-more#sha384",
|
|
52
|
-
"sha512" => "http://www.w3.org/2001/04/xmlenc#sha512"
|
|
53
|
-
}.freeze
|
|
54
|
-
|
|
55
|
-
SSO_SAML_SECURE_SIGNATURE_ALGORITHMS = (SSO_SAML_SIGNATURE_ALGORITHMS.values - ["http://www.w3.org/2000/09/xmldsig#rsa-sha1"]).uniq.freeze
|
|
56
|
-
SSO_SAML_SECURE_DIGEST_ALGORITHMS = (SSO_SAML_DIGEST_ALGORITHMS.values - ["http://www.w3.org/2000/09/xmldsig#sha1"]).uniq.freeze
|
|
57
|
-
SSO_SAML_SECURE_KEY_ENCRYPTION_ALGORITHMS = %w[
|
|
58
|
-
http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p
|
|
59
|
-
http://www.w3.org/2009/xmlenc11#rsa-oaep
|
|
60
|
-
].freeze
|
|
61
|
-
SSO_SAML_SECURE_DATA_ENCRYPTION_ALGORITHMS = %w[
|
|
62
|
-
http://www.w3.org/2001/04/xmlenc#aes128-cbc
|
|
63
|
-
http://www.w3.org/2001/04/xmlenc#aes192-cbc
|
|
64
|
-
http://www.w3.org/2001/04/xmlenc#aes256-cbc
|
|
65
|
-
http://www.w3.org/2009/xmlenc11#aes128-gcm
|
|
66
|
-
http://www.w3.org/2009/xmlenc11#aes192-gcm
|
|
67
|
-
http://www.w3.org/2009/xmlenc11#aes256-gcm
|
|
68
|
-
].freeze
|
|
69
|
-
SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024
|
|
70
|
-
SSO_DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024
|
|
71
|
-
SSO_SAML_RELAY_STATE_KEY_PREFIX = "saml-relay-state:"
|
|
72
|
-
SSO_SAML_AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:"
|
|
73
|
-
SSO_DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000
|
|
74
|
-
SSO_SAML_USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:"
|
|
75
|
-
SSO_DEFAULT_ASSERTION_TTL_MS = 15 * 60 * 1000
|
|
76
|
-
SSO_DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000
|
|
77
|
-
SSO_SAML_SESSION_KEY_PREFIX = "saml-session:"
|
|
78
|
-
SSO_SAML_SESSION_BY_ID_KEY_PREFIX = "saml-session-by-id:"
|
|
79
|
-
SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX = "saml-logout-request:"
|
|
80
|
-
SSO_SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
|
|
81
|
-
SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS = 5 * 60 * 1000
|
|
82
|
-
|
|
83
|
-
def sso(options = {})
|
|
84
|
-
config = normalize_hash(options)
|
|
85
|
-
if defined?(BetterAuth::SSO::SAML) && defined?(BetterAuth::SSO::SAMLHooks)
|
|
86
|
-
config = BetterAuth::SSO::SAMLHooks.merge_options(BetterAuth::SSO::SAML.sso_options, config)
|
|
87
|
-
end
|
|
88
|
-
endpoints = BetterAuth::SSO::Routes::SSO.endpoints(config)
|
|
89
|
-
Plugin.new(
|
|
90
|
-
id: "sso",
|
|
91
|
-
init: ->(_ctx) { {options: {advanced: {disable_origin_check: ["/sso/saml2/callback", "/sso/saml2/sp/acs", "/sso/saml2/sp/slo"]}}} },
|
|
92
|
-
schema: BetterAuth::SSO::Routes::Schemas.plugin_schema(config),
|
|
93
|
-
endpoints: endpoints,
|
|
94
|
-
error_codes: SSO_ERROR_CODES,
|
|
95
|
-
options: config
|
|
96
|
-
)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def sso_schema(config = {})
|
|
100
|
-
BetterAuth::SSO::Routes::Schemas.plugin_schema(config)
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def sso_discover_oidc_config(issuer:, fetch: nil, existing_config: nil, discovery_endpoint: nil, trusted_origin: nil, timeout: nil)
|
|
104
|
-
existing = normalize_hash(existing_config || {})
|
|
105
|
-
discovery_url = discovery_endpoint || existing[:discovery_endpoint] || "#{issuer.to_s.sub(%r{/+\z}, "")}/.well-known/openid-configuration"
|
|
106
|
-
if trusted_origin && !trusted_origin.call(discovery_url)
|
|
107
|
-
raise APIError.new("BAD_REQUEST", message: "OIDC discovery endpoint is not trusted")
|
|
108
|
-
end
|
|
109
|
-
document = if fetch
|
|
110
|
-
fetch.call(discovery_url)
|
|
111
|
-
else
|
|
112
|
-
uri = URI(discovery_url)
|
|
113
|
-
JSON.parse(Net::HTTP.get(uri))
|
|
114
|
-
end
|
|
115
|
-
document = normalize_hash(document)
|
|
116
|
-
valid = document[:issuer].to_s.sub(%r{/+\z}, "") == issuer.to_s.sub(%r{/+\z}, "") &&
|
|
117
|
-
!document[:authorization_endpoint].to_s.empty? &&
|
|
118
|
-
!document[:token_endpoint].to_s.empty? &&
|
|
119
|
-
!document[:jwks_uri].to_s.empty?
|
|
120
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document") unless valid
|
|
121
|
-
|
|
122
|
-
authorization_endpoint = sso_normalize_discovery_url(document[:authorization_endpoint], issuer, trusted_origin)
|
|
123
|
-
token_endpoint = sso_normalize_discovery_url(document[:token_endpoint], issuer, trusted_origin)
|
|
124
|
-
jwks_endpoint = sso_normalize_discovery_url(document[:jwks_uri], issuer, trusted_origin)
|
|
125
|
-
user_info_endpoint = document[:userinfo_endpoint] && sso_normalize_discovery_url(document[:userinfo_endpoint], issuer, trusted_origin)
|
|
126
|
-
auth_methods = Array(document[:token_endpoint_auth_methods_supported])
|
|
127
|
-
token_endpoint_authentication = if existing[:token_endpoint_authentication]
|
|
128
|
-
existing[:token_endpoint_authentication]
|
|
129
|
-
elsif auth_methods.include?("client_secret_post") && !auth_methods.include?("client_secret_basic")
|
|
130
|
-
"client_secret_post"
|
|
131
|
-
else
|
|
132
|
-
"client_secret_basic"
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
{
|
|
136
|
-
issuer: existing[:issuer] || document[:issuer],
|
|
137
|
-
discovery_endpoint: existing[:discovery_endpoint] || discovery_url,
|
|
138
|
-
client_id: existing[:client_id],
|
|
139
|
-
authorization_endpoint: existing[:authorization_endpoint] || authorization_endpoint,
|
|
140
|
-
token_endpoint: existing[:token_endpoint] || token_endpoint,
|
|
141
|
-
jwks_endpoint: existing[:jwks_endpoint] || jwks_endpoint,
|
|
142
|
-
user_info_endpoint: existing[:user_info_endpoint] || user_info_endpoint,
|
|
143
|
-
token_endpoint_authentication: token_endpoint_authentication,
|
|
144
|
-
scopes_supported: existing[:scopes_supported] || document[:scopes_supported]
|
|
145
|
-
}.compact
|
|
146
|
-
rescue APIError
|
|
147
|
-
raise
|
|
148
|
-
rescue
|
|
149
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document")
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def sso_normalize_discovery_url(value, issuer, trusted_origin)
|
|
153
|
-
uri = URI(value.to_s)
|
|
154
|
-
normalized = if uri.absolute?
|
|
155
|
-
uri.to_s
|
|
156
|
-
else
|
|
157
|
-
issuer_uri = URI(issuer.to_s)
|
|
158
|
-
issuer_base = issuer_uri.to_s.sub(%r{/+\z}, "")
|
|
159
|
-
endpoint = value.to_s.sub(%r{\A/+}, "")
|
|
160
|
-
"#{issuer_base}/#{endpoint}"
|
|
161
|
-
end
|
|
162
|
-
if trusted_origin && !trusted_origin.call(normalized)
|
|
163
|
-
raise APIError.new("BAD_REQUEST", message: "OIDC discovery endpoint is not trusted")
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
normalized
|
|
167
|
-
rescue URI::InvalidURIError
|
|
168
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document")
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def sso_register_provider_endpoint(config = {})
|
|
172
|
-
Endpoint.new(path: "/sso/register", method: "POST") do |ctx|
|
|
173
|
-
session = Routes.current_session(ctx)
|
|
174
|
-
body = normalize_hash(ctx.body)
|
|
175
|
-
provider_id = body[:provider_id].to_s
|
|
176
|
-
raise APIError.new("BAD_REQUEST", message: "providerId is required") if provider_id.empty?
|
|
177
|
-
|
|
178
|
-
limit = sso_provider_limit(session.fetch(:user), config)
|
|
179
|
-
if limit.to_i.zero?
|
|
180
|
-
raise APIError.new("FORBIDDEN", message: "SSO provider registration is disabled")
|
|
181
|
-
end
|
|
182
|
-
providers = ctx.context.adapter.find_many(model: "ssoProvider", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
|
|
183
|
-
if providers.length >= limit.to_i
|
|
184
|
-
raise APIError.new("FORBIDDEN", message: "You have reached the maximum number of SSO providers")
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
sso_validate_url!(body[:issuer], "Invalid issuer. Must be a valid URL")
|
|
188
|
-
sso_validate_organization_membership!(ctx, session.fetch(:user).fetch("id"), body[:organization_id]) if body[:organization_id]
|
|
189
|
-
if ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id}])
|
|
190
|
-
raise APIError.new("UNPROCESSABLE_ENTITY", message: "SSO provider with this providerId already exists")
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
oidc_config = normalize_hash(body[:oidc_config] || {})
|
|
194
|
-
oidc_config = sso_hydrate_oidc_config(body[:issuer], oidc_config, ctx) if oidc_config.any? && !oidc_config[:skip_discovery]
|
|
195
|
-
oidc_config[:override_user_info] = !!(body[:override_user_info] || config[:default_override_user_info]) if oidc_config.any?
|
|
196
|
-
saml_config = normalize_hash(body[:saml_config] || {})
|
|
197
|
-
sso_validate_saml_config!(saml_config, config) unless saml_config.empty?
|
|
198
|
-
|
|
199
|
-
provider = ctx.context.adapter.create(
|
|
200
|
-
model: "ssoProvider",
|
|
201
|
-
data: {
|
|
202
|
-
providerId: provider_id,
|
|
203
|
-
issuer: body[:issuer].to_s,
|
|
204
|
-
domain: body[:domain].to_s.downcase,
|
|
205
|
-
oidcConfig: oidc_config.empty? ? nil : oidc_config,
|
|
206
|
-
samlConfig: saml_config.empty? ? nil : saml_config,
|
|
207
|
-
userId: session.fetch(:user).fetch("id"),
|
|
208
|
-
organizationId: body[:organization_id],
|
|
209
|
-
domainVerified: false
|
|
210
|
-
}
|
|
211
|
-
)
|
|
212
|
-
domain_verification_token = nil
|
|
213
|
-
if config.dig(:domain_verification, :enabled)
|
|
214
|
-
domain_verification_token = BetterAuth::Crypto.random_string(24)
|
|
215
|
-
ctx.context.internal_adapter.create_verification_value(
|
|
216
|
-
identifier: sso_domain_verification_identifier(config, provider.fetch("providerId")),
|
|
217
|
-
value: domain_verification_token,
|
|
218
|
-
expiresAt: Time.now + (7 * 24 * 60 * 60)
|
|
219
|
-
)
|
|
220
|
-
end
|
|
221
|
-
response = sso_sanitize_provider(provider, ctx.context)
|
|
222
|
-
response[:redirectURI] = sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId"))
|
|
223
|
-
response[:domainVerificationToken] = domain_verification_token if domain_verification_token
|
|
224
|
-
ctx.json(response)
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def sso_list_providers_endpoint
|
|
229
|
-
Endpoint.new(path: "/sso/providers", method: "GET") do |ctx|
|
|
230
|
-
session = Routes.current_session(ctx)
|
|
231
|
-
providers = ctx.context.adapter.find_many(model: "ssoProvider")
|
|
232
|
-
.select { |provider| sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx) }
|
|
233
|
-
.map { |provider| sso_sanitize_provider(provider, ctx.context) }
|
|
234
|
-
ctx.json({providers: providers})
|
|
235
|
-
end
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def sso_get_provider_endpoint
|
|
239
|
-
Endpoint.new(path: "/sso/get-provider", method: "GET") do |ctx|
|
|
240
|
-
session = Routes.current_session(ctx)
|
|
241
|
-
provider = sso_find_provider!(ctx, sso_fetch(ctx.query, :provider_id) || sso_fetch(ctx.params, :provider_id))
|
|
242
|
-
raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
|
|
243
|
-
|
|
244
|
-
ctx.json(sso_sanitize_provider(provider, ctx.context))
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
def sso_update_provider_endpoint
|
|
249
|
-
Endpoint.new(path: "/sso/update-provider", method: "POST") do |ctx|
|
|
250
|
-
session = Routes.current_session(ctx)
|
|
251
|
-
body = normalize_hash(ctx.body)
|
|
252
|
-
provider = sso_find_provider!(ctx, sso_fetch(body, :provider_id) || sso_fetch(ctx.params, :provider_id))
|
|
253
|
-
raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
|
|
254
|
-
|
|
255
|
-
if !body.key?(:issuer) && !body.key?(:domain) && !body.key?(:oidc_config) && !body.key?(:saml_config)
|
|
256
|
-
raise APIError.new("BAD_REQUEST", message: "No fields provided for update")
|
|
257
|
-
end
|
|
258
|
-
sso_validate_url!(body[:issuer], "Invalid issuer. Must be a valid URL") if body.key?(:issuer)
|
|
259
|
-
update = {}
|
|
260
|
-
update[:issuer] = body[:issuer] if body.key?(:issuer)
|
|
261
|
-
update[:domain] = body[:domain].to_s.downcase if body.key?(:domain)
|
|
262
|
-
update[:domainVerified] = false if body.key?(:domain) && body[:domain].to_s.downcase != provider["domain"].to_s
|
|
263
|
-
if body.key?(:oidc_config)
|
|
264
|
-
current = sso_provider_config_hash(provider["oidcConfig"])
|
|
265
|
-
raise APIError.new("BAD_REQUEST", message: "Cannot update OIDC config for a provider that doesn't have OIDC configured") if current.empty?
|
|
266
|
-
|
|
267
|
-
update[:oidcConfig] = current.merge(normalize_hash(body[:oidc_config]))
|
|
268
|
-
end
|
|
269
|
-
if body.key?(:saml_config)
|
|
270
|
-
current = sso_provider_config_hash(provider["samlConfig"])
|
|
271
|
-
raise APIError.new("BAD_REQUEST", message: "Cannot update SAML config for a provider that doesn't have SAML configured") if current.empty?
|
|
272
|
-
|
|
273
|
-
update[:samlConfig] = current.merge(normalize_hash(body[:saml_config]))
|
|
274
|
-
end
|
|
275
|
-
updated = ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: update)
|
|
276
|
-
ctx.json(sso_sanitize_provider(updated, ctx.context))
|
|
277
|
-
end
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
def sso_delete_provider_endpoint
|
|
281
|
-
Endpoint.new(path: "/sso/delete-provider", method: "POST") do |ctx|
|
|
282
|
-
session = Routes.current_session(ctx)
|
|
283
|
-
provider = sso_find_provider!(ctx, sso_fetch(ctx.body, :provider_id) || sso_fetch(ctx.params, :provider_id))
|
|
284
|
-
raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
|
|
285
|
-
|
|
286
|
-
ctx.context.adapter.delete(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}])
|
|
287
|
-
ctx.json({success: true})
|
|
288
|
-
end
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
def sso_sign_in_endpoint(config = {})
|
|
292
|
-
Endpoint.new(path: "/sign-in/sso", method: "POST") do |ctx|
|
|
293
|
-
body = normalize_hash(ctx.body)
|
|
294
|
-
provider = sso_select_provider(ctx, body, config)
|
|
295
|
-
provider_type = body[:provider_type].to_s
|
|
296
|
-
if provider_type == "oidc" && !provider["oidcConfig"]
|
|
297
|
-
raise APIError.new("BAD_REQUEST", message: "OIDC provider is not configured")
|
|
298
|
-
end
|
|
299
|
-
if provider_type == "saml" && !provider["samlConfig"]
|
|
300
|
-
raise APIError.new("BAD_REQUEST", message: "SAML provider is not configured")
|
|
301
|
-
end
|
|
302
|
-
if config.dig(:domain_verification, :enabled) && !(provider.key?("domainVerified") && provider["domainVerified"])
|
|
303
|
-
raise APIError.new("UNAUTHORIZED", message: "Provider domain has not been verified")
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
state_data = {
|
|
307
|
-
providerId: provider.fetch("providerId"),
|
|
308
|
-
callbackURL: body[:callback_url] || "/",
|
|
309
|
-
errorURL: body[:error_callback_url],
|
|
310
|
-
newUserURL: body[:new_user_callback_url],
|
|
311
|
-
requestSignUp: body[:request_sign_up]
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
if provider["oidcConfig"] && provider_type != "saml"
|
|
315
|
-
provider = sso_ensure_runtime_oidc_provider(ctx, provider, config)
|
|
316
|
-
state = BetterAuth::Crypto.sign_jwt(state_data.merge(sso_oidc_pkce_state(provider)), ctx.context.secret, expires_in: 600)
|
|
317
|
-
url = sso_oidc_authorization_url(provider, ctx, state, config, body)
|
|
318
|
-
elsif provider["samlConfig"]
|
|
319
|
-
relay_state = sso_generate_saml_relay_state(ctx, state_data)
|
|
320
|
-
url = sso_saml_authorization_url(provider, relay_state, ctx, config)
|
|
321
|
-
sso_store_saml_authn_request(ctx, provider, url, config)
|
|
322
|
-
else
|
|
323
|
-
raise APIError.new("BAD_REQUEST", message: "OIDC provider is not configured")
|
|
324
|
-
end
|
|
325
|
-
ctx.json({url: url, redirect: true})
|
|
326
|
-
end
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
def sso_oidc_callback_endpoint(config = {})
|
|
330
|
-
Endpoint.new(path: "/sso/callback/:providerId", method: "GET") do |ctx|
|
|
331
|
-
sso_handle_oidc_callback(ctx, config, sso_fetch(ctx.params, :provider_id))
|
|
332
|
-
end
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
def sso_oidc_shared_callback_endpoint(config = {})
|
|
336
|
-
Endpoint.new(path: "/sso/callback", method: "GET") do |ctx|
|
|
337
|
-
state = sso_verify_state(ctx.query[:state] || ctx.query["state"], ctx.context.secret)
|
|
338
|
-
next ctx.redirect("#{ctx.context.base_url}/error?error=invalid_state") unless state
|
|
339
|
-
|
|
340
|
-
sso_handle_oidc_callback(ctx, config, state["providerId"], state: state)
|
|
341
|
-
end
|
|
342
|
-
end
|
|
343
|
-
|
|
344
|
-
def sso_handle_oidc_callback(ctx, config, provider_id, state: nil)
|
|
345
|
-
state ||= sso_verify_state(ctx.query[:state] || ctx.query["state"], ctx.context.secret)
|
|
346
|
-
return ctx.redirect("#{ctx.context.base_url}/error?error=invalid_state") unless state
|
|
347
|
-
|
|
348
|
-
callback_url = state["callbackURL"] || "/"
|
|
349
|
-
error_url = state["errorURL"] || callback_url
|
|
350
|
-
if ctx.query[:error] || ctx.query["error"]
|
|
351
|
-
error = ctx.query[:error] || ctx.query["error"]
|
|
352
|
-
description = ctx.query[:error_description] || ctx.query["error_description"]
|
|
353
|
-
return sso_redirect(ctx, sso_append_error(error_url, error, description))
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
provider = sso_callback_provider(ctx, config, provider_id)
|
|
357
|
-
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "provider not found")) unless provider
|
|
358
|
-
if config.dig(:domain_verification, :enabled) && !(provider.key?("domainVerified") && provider["domainVerified"])
|
|
359
|
-
raise APIError.new("UNAUTHORIZED", message: "Provider domain has not been verified")
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
provider = sso_ensure_runtime_oidc_provider(ctx, provider, config)
|
|
363
|
-
oidc_config = normalize_hash(provider["oidcConfig"] || {})
|
|
364
|
-
oidc_config[:issuer] ||= provider["issuer"]
|
|
365
|
-
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "provider not found")) if oidc_config.empty?
|
|
366
|
-
|
|
367
|
-
tokens = sso_oidc_tokens(ctx, provider, oidc_config, state, config)
|
|
368
|
-
unless tokens
|
|
369
|
-
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "token_response_not_found"))
|
|
370
|
-
end
|
|
371
|
-
if oidc_config[:user_info_endpoint].to_s.empty? && tokens[:id_token] && oidc_config[:jwks_endpoint].to_s.empty?
|
|
372
|
-
begin
|
|
373
|
-
provider = sso_ensure_runtime_oidc_provider(ctx, provider, config, require_jwks: true)
|
|
374
|
-
oidc_config = normalize_hash(provider["oidcConfig"] || {})
|
|
375
|
-
oidc_config[:issuer] ||= provider["issuer"]
|
|
376
|
-
rescue APIError
|
|
377
|
-
# Fall through to the upstream callback error when JWKS is still unavailable.
|
|
378
|
-
end
|
|
379
|
-
end
|
|
380
|
-
user_info = sso_oidc_user_info(ctx, oidc_config, tokens, config)
|
|
381
|
-
if user_info[:_sso_error]
|
|
382
|
-
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", user_info[:_sso_error]))
|
|
383
|
-
end
|
|
384
|
-
if user_info[:email].to_s.empty? || user_info[:id].to_s.empty?
|
|
385
|
-
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "missing_user_info"))
|
|
386
|
-
end
|
|
387
|
-
if config[:disable_implicit_sign_up] && !state["requestSignUp"] && !ctx.context.internal_adapter.find_user_by_email(user_info[:email].to_s.downcase)
|
|
388
|
-
return sso_redirect(ctx, sso_append_error(error_url, "signup disabled"))
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
result = sso_find_or_create_user_result(ctx, provider, user_info, config)
|
|
392
|
-
if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
|
|
393
|
-
config[:provision_user].call(user: result.fetch(:user), userInfo: user_info, token: tokens, provider: provider)
|
|
394
|
-
end
|
|
395
|
-
session = ctx.context.internal_adapter.create_session(result.fetch(:user).fetch("id"))
|
|
396
|
-
Cookies.set_session_cookie(ctx, {session: session, user: result.fetch(:user)})
|
|
397
|
-
redirect_to = (result.fetch(:created) && state["newUserURL"].to_s != "") ? state["newUserURL"] : callback_url
|
|
398
|
-
sso_redirect(ctx, redirect_to || "/")
|
|
399
|
-
end
|
|
400
|
-
|
|
401
|
-
def sso_saml_callback_endpoint(config)
|
|
402
|
-
Endpoint.new(path: "/sso/saml2/callback/:providerId", method: ["GET", "POST"], metadata: {allowed_media_types: ["application/json", "application/x-www-form-urlencoded"]}) do |ctx|
|
|
403
|
-
sso_handle_saml_response(ctx, config)
|
|
404
|
-
end
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
def sso_saml_acs_endpoint(config)
|
|
408
|
-
Endpoint.new(path: "/sso/saml2/sp/acs/:providerId", method: "POST", metadata: {allowed_media_types: ["application/json", "application/x-www-form-urlencoded"]}) do |ctx|
|
|
409
|
-
sso_handle_saml_response(ctx, config)
|
|
410
|
-
end
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
def sso_sp_metadata_endpoint(config = {})
|
|
414
|
-
Endpoint.new(path: "/sso/saml2/sp/metadata", method: "GET") do |ctx|
|
|
415
|
-
provider = sso_find_provider!(ctx, sso_fetch(ctx.query, :provider_id))
|
|
416
|
-
metadata = sso_sp_metadata_xml(ctx, provider, config)
|
|
417
|
-
if (ctx.query[:format] || ctx.query["format"]) == "json"
|
|
418
|
-
ctx.json({providerId: provider.fetch("providerId"), metadata: metadata})
|
|
419
|
-
else
|
|
420
|
-
ctx.set_header("content-type", "application/samlmetadata+xml")
|
|
421
|
-
ctx.json(metadata)
|
|
422
|
-
end
|
|
423
|
-
end
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
def sso_saml_slo_endpoint(config = {})
|
|
427
|
-
Endpoint.new(path: "/sso/saml2/sp/slo/:providerId", method: ["GET", "POST"], metadata: {allowed_media_types: ["application/json", "application/x-www-form-urlencoded"]}) do |ctx|
|
|
428
|
-
raise APIError.new("BAD_REQUEST", message: "Single Logout is not enabled") unless config.dig(:saml, :enable_single_logout)
|
|
429
|
-
|
|
430
|
-
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
431
|
-
relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
|
|
432
|
-
if sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response)
|
|
433
|
-
sso_process_saml_logout_response(ctx, sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response))
|
|
434
|
-
Cookies.delete_session_cookie(ctx)
|
|
435
|
-
next sso_redirect(ctx, sso_safe_slo_redirect_url(ctx, relay_state, provider.fetch("providerId")))
|
|
436
|
-
end
|
|
437
|
-
|
|
438
|
-
sso_process_saml_logout_request(ctx, provider, sso_fetch(ctx.body, :saml_request) || sso_fetch(ctx.query, :saml_request))
|
|
439
|
-
response = Base64.strict_encode64("<samlp:LogoutResponse xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" ID=\"_#{BetterAuth::Crypto.random_string(32)}\" Version=\"2.0\" IssueInstant=\"#{Time.now.utc.iso8601}\" Destination=\"#{sso_saml_logout_destination(provider)}\"><samlp:Status><samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/></samlp:Status></samlp:LogoutResponse>")
|
|
440
|
-
if sso_fetch(ctx.body, :saml_request)
|
|
441
|
-
next sso_saml_post_form(sso_saml_logout_destination(provider), "SAMLResponse", response, relay_state)
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
query = {SAMLResponse: response, RelayState: relay_state}
|
|
445
|
-
query = sso_signed_saml_redirect_query(provider, query) if config.dig(:saml, :want_logout_response_signed)
|
|
446
|
-
sso_redirect(ctx, "#{sso_saml_logout_destination(provider)}?#{URI.encode_www_form(query)}")
|
|
447
|
-
end
|
|
448
|
-
end
|
|
449
|
-
|
|
450
|
-
def sso_initiate_slo_endpoint(config = {})
|
|
451
|
-
Endpoint.new(path: "/sso/saml2/logout/:providerId", method: "POST") do |ctx|
|
|
452
|
-
raise APIError.new("BAD_REQUEST", message: "Single Logout is not enabled") unless config.dig(:saml, :enable_single_logout)
|
|
453
|
-
|
|
454
|
-
session = Routes.current_session(ctx)
|
|
455
|
-
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
456
|
-
destination = sso_saml_logout_destination(provider)
|
|
457
|
-
if destination.to_s.empty?
|
|
458
|
-
raise APIError.new("BAD_REQUEST", message: "IdP does not support Single Logout Service")
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
relay_state = sso_fetch(ctx.body, :callback_url) || ctx.context.base_url
|
|
462
|
-
session_token = session.fetch(:session).fetch("token")
|
|
463
|
-
user_email = session.fetch(:user).fetch("email")
|
|
464
|
-
saml_session_key = ctx.context.internal_adapter.find_verification_value("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}")&.fetch("value")
|
|
465
|
-
saml_session = saml_session_key && ctx.context.internal_adapter.find_verification_value(saml_session_key)
|
|
466
|
-
saml_record = saml_session ? JSON.parse(saml_session.fetch("value")) : {}
|
|
467
|
-
name_id = saml_record["nameId"] || user_email
|
|
468
|
-
session_index = saml_record["sessionIndex"]
|
|
469
|
-
|
|
470
|
-
request_id = "_#{BetterAuth::Crypto.random_string(32)}"
|
|
471
|
-
session_index_xml = session_index.to_s.empty? ? "" : "<samlp:SessionIndex>#{CGI.escapeHTML(session_index.to_s)}</samlp:SessionIndex>"
|
|
472
|
-
request = Base64.strict_encode64("<samlp:LogoutRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" ID=\"#{request_id}\" Version=\"2.0\" IssueInstant=\"#{Time.now.utc.iso8601}\" Destination=\"#{CGI.escapeHTML(destination.to_s)}\"><saml:NameID>#{CGI.escapeHTML(name_id.to_s)}</saml:NameID>#{session_index_xml}</samlp:LogoutRequest>")
|
|
473
|
-
sso_store_saml_logout_request(ctx, provider, request_id, config)
|
|
474
|
-
ctx.context.internal_adapter.delete_verification_by_identifier(saml_session_key) if saml_session_key
|
|
475
|
-
ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}")
|
|
476
|
-
ctx.context.internal_adapter.delete_session(session_token)
|
|
477
|
-
Cookies.delete_session_cookie(ctx)
|
|
478
|
-
query = {SAMLRequest: request, RelayState: relay_state}
|
|
479
|
-
query = sso_signed_saml_redirect_query(provider, query) if config.dig(:saml, :want_logout_request_signed)
|
|
480
|
-
sso_redirect(ctx, "#{destination}?#{URI.encode_www_form(query)}")
|
|
481
|
-
end
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
def sso_request_domain_verification_endpoint(config)
|
|
485
|
-
Endpoint.new(path: "/sso/request-domain-verification", method: "POST") do |ctx|
|
|
486
|
-
session = Routes.current_session(ctx)
|
|
487
|
-
provider = sso_find_provider!(ctx, normalize_hash(ctx.body)[:provider_id])
|
|
488
|
-
sso_authorize_domain_verification!(ctx, provider, session.fetch(:user).fetch("id"))
|
|
489
|
-
if provider.key?("domainVerified") && provider["domainVerified"]
|
|
490
|
-
raise APIError.new("CONFLICT", message: "Domain has already been verified", code: "DOMAIN_VERIFIED")
|
|
491
|
-
end
|
|
492
|
-
|
|
493
|
-
identifier = sso_domain_verification_identifier(config, provider.fetch("providerId"))
|
|
494
|
-
active = ctx.context.internal_adapter.find_verification_value(identifier)
|
|
495
|
-
if active && sso_future_time?(active.fetch("expiresAt"))
|
|
496
|
-
next ctx.json({domainVerificationToken: active.fetch("value")}, status: 201)
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
token = BetterAuth::Crypto.random_string(24)
|
|
500
|
-
ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: token, expiresAt: Time.now + (7 * 24 * 60 * 60))
|
|
501
|
-
config.dig(:domain_verification, :request)&.call(provider: provider, token: token, context: ctx)
|
|
502
|
-
ctx.json({domainVerificationToken: token}, status: 201)
|
|
503
|
-
end
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
def sso_verify_domain_endpoint(config)
|
|
507
|
-
Endpoint.new(path: "/sso/verify-domain", method: "POST") do |ctx|
|
|
508
|
-
session = Routes.current_session(ctx)
|
|
509
|
-
provider = sso_find_provider!(ctx, normalize_hash(ctx.body)[:provider_id])
|
|
510
|
-
sso_authorize_domain_verification!(ctx, provider, session.fetch(:user).fetch("id"))
|
|
511
|
-
if provider.key?("domainVerified") && provider["domainVerified"]
|
|
512
|
-
raise APIError.new("CONFLICT", message: "Domain has already been verified", code: "DOMAIN_VERIFIED")
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
identifier = sso_domain_verification_identifier(config, provider.fetch("providerId"))
|
|
516
|
-
if identifier.length > 63
|
|
517
|
-
raise APIError.new("BAD_REQUEST", message: "Verification identifier exceeds the DNS label limit of 63 characters", code: "IDENTIFIER_TOO_LONG")
|
|
518
|
-
end
|
|
519
|
-
active = ctx.context.internal_adapter.find_verification_value(identifier)
|
|
520
|
-
if !active || !sso_future_time?(active.fetch("expiresAt"))
|
|
521
|
-
raise APIError.new("NOT_FOUND", message: "No pending domain verification exists", code: "NO_PENDING_VERIFICATION")
|
|
522
|
-
end
|
|
523
|
-
|
|
524
|
-
hostname = sso_hostname_from_domain(provider.fetch("domain"))
|
|
525
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid domain", code: "INVALID_DOMAIN") if hostname.to_s.empty?
|
|
526
|
-
|
|
527
|
-
records = sso_resolve_txt_records("#{identifier}.#{hostname}", config)
|
|
528
|
-
expected = "#{identifier}=#{active.fetch("value")}"
|
|
529
|
-
unless records.flatten.any? { |record| record.to_s.include?(expected) }
|
|
530
|
-
raise APIError.new("BAD_GATEWAY", message: "Unable to verify domain ownership. Try again later", code: "DOMAIN_VERIFICATION_FAILED")
|
|
531
|
-
end
|
|
532
|
-
|
|
533
|
-
ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: {domainVerified: true})
|
|
534
|
-
ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
|
|
535
|
-
ctx.set_status(204)
|
|
536
|
-
nil
|
|
537
|
-
end
|
|
538
|
-
end
|
|
539
|
-
|
|
540
|
-
def sso_handle_saml_response(ctx, config = {})
|
|
541
|
-
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
542
|
-
relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
|
|
543
|
-
state = sso_parse_saml_relay_state(ctx, relay_state) || {}
|
|
544
|
-
raw_response = sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response)
|
|
545
|
-
if ctx.method == "GET" && raw_response.to_s.empty?
|
|
546
|
-
session = Routes.current_session(ctx, allow_nil: true)
|
|
547
|
-
unless session
|
|
548
|
-
return sso_redirect(ctx, sso_append_error("#{ctx.context.base_url}/error", "invalid_request"))
|
|
549
|
-
end
|
|
550
|
-
|
|
551
|
-
return sso_redirect(ctx, sso_safe_saml_callback_url(ctx, relay_state || sso_saml_callback_url(provider) || "/", provider.fetch("providerId")))
|
|
552
|
-
end
|
|
553
|
-
max_response_size = config.dig(:saml, :max_response_size) || SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE
|
|
554
|
-
if raw_response.to_s.bytesize > max_response_size
|
|
555
|
-
raise APIError.new("BAD_REQUEST", message: "SAML response exceeds maximum allowed size (#{max_response_size} bytes)")
|
|
556
|
-
end
|
|
557
|
-
in_response_to_error = sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
|
|
558
|
-
return in_response_to_error if in_response_to_error
|
|
559
|
-
|
|
560
|
-
assertion = sso_parse_saml_response(raw_response, config, provider, ctx)
|
|
561
|
-
assertion[:email_verified] = false unless config[:trust_email_verified]
|
|
562
|
-
sso_validate_saml_timestamp!(sso_saml_timestamp_conditions(assertion), config)
|
|
563
|
-
sso_validate_saml_response!(config, assertion, provider, ctx)
|
|
564
|
-
assertion_id = assertion[:id] || assertion["id"] || assertion[:email]
|
|
565
|
-
replay_key = "#{SSO_SAML_USED_ASSERTION_KEY_PREFIX}#{assertion_id}"
|
|
566
|
-
if ctx.context.internal_adapter.find_verification_value(replay_key)
|
|
567
|
-
raise APIError.new("BAD_REQUEST", message: SSO_ERROR_CODES.fetch("SAML_RESPONSE_REPLAYED"))
|
|
568
|
-
end
|
|
569
|
-
ctx.context.internal_adapter.create_verification_value(identifier: replay_key, value: "used", expiresAt: sso_saml_assertion_replay_expires_at(assertion, config))
|
|
570
|
-
|
|
571
|
-
callback_url = sso_safe_saml_callback_url(ctx, state["callbackURL"] || sso_saml_callback_url(provider) || "/", provider.fetch("providerId"))
|
|
572
|
-
email = (assertion[:email] || assertion["email"]).to_s.downcase
|
|
573
|
-
if config[:disable_implicit_sign_up] && !state["requestSignUp"] && !ctx.context.internal_adapter.find_user_by_email(email)
|
|
574
|
-
return sso_redirect(ctx, sso_append_error(callback_url, "signup disabled"))
|
|
575
|
-
end
|
|
576
|
-
|
|
577
|
-
result = sso_find_or_create_user_result(ctx, provider, assertion, config)
|
|
578
|
-
return sso_redirect(ctx, sso_append_error(callback_url, result.fetch(:error))) if result[:error]
|
|
579
|
-
|
|
580
|
-
user = result.fetch(:user)
|
|
581
|
-
if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
|
|
582
|
-
config[:provision_user].call(user: user, userInfo: assertion, provider: provider)
|
|
583
|
-
end
|
|
584
|
-
session = ctx.context.internal_adapter.create_session(user.fetch("id"))
|
|
585
|
-
sso_store_saml_session(ctx, provider, assertion, session) if config.dig(:saml, :enable_single_logout)
|
|
586
|
-
Cookies.set_session_cookie(ctx, {session: session, user: user})
|
|
587
|
-
sso_redirect(ctx, callback_url)
|
|
588
|
-
end
|
|
589
|
-
|
|
590
|
-
def sso_find_or_create_user(ctx, provider, user_info, config = {})
|
|
591
|
-
sso_find_or_create_user_result(ctx, provider, user_info, config).fetch(:user)
|
|
592
|
-
end
|
|
593
|
-
|
|
594
|
-
def sso_find_or_create_user_result(ctx, provider, user_info, config = {})
|
|
595
|
-
user_info = normalize_hash(user_info)
|
|
596
|
-
email = user_info[:email].to_s.downcase
|
|
597
|
-
account_id = (user_info[:id] || user_info["id"]).to_s
|
|
598
|
-
provider_id = provider.fetch("providerId")
|
|
599
|
-
storage_provider_id = provider["samlConfig"] ? provider_id : "sso:#{provider_id}"
|
|
600
|
-
existing_account = account_id.empty? ? nil : (
|
|
601
|
-
ctx.context.internal_adapter.find_account_by_provider_id(account_id, provider_id) ||
|
|
602
|
-
ctx.context.internal_adapter.find_account_by_provider_id(account_id, "sso:#{provider_id}")
|
|
603
|
-
)
|
|
604
|
-
if existing_account
|
|
605
|
-
user = ctx.context.internal_adapter.find_user_by_id(existing_account.fetch("userId"))
|
|
606
|
-
created = false
|
|
607
|
-
elsif (found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true))
|
|
608
|
-
already_linked_provider = Array(found[:accounts]).any? do |account|
|
|
609
|
-
[provider_id, "sso:#{provider_id}"].include?(account["providerId"])
|
|
610
|
-
end
|
|
611
|
-
if provider["samlConfig"]
|
|
612
|
-
return {error: "account_not_linked"} unless already_linked_provider || sso_saml_trusted_provider?(ctx, provider, email)
|
|
613
|
-
end
|
|
614
|
-
|
|
615
|
-
user = found[:user]
|
|
616
|
-
unless account_id.empty?
|
|
617
|
-
ctx.context.internal_adapter.create_account(
|
|
618
|
-
accountId: account_id,
|
|
619
|
-
providerId: storage_provider_id,
|
|
620
|
-
userId: user.fetch("id")
|
|
621
|
-
)
|
|
622
|
-
end
|
|
623
|
-
oidc_config = normalize_hash(provider["oidcConfig"] || {})
|
|
624
|
-
if oidc_config[:override_user_info] || config[:default_override_user_info]
|
|
625
|
-
update = {}
|
|
626
|
-
update[:name] = user_info[:name] if user_info.key?(:name)
|
|
627
|
-
update[:image] = user_info[:image] if user_info.key?(:image)
|
|
628
|
-
update[:emailVerified] = !!user_info[:email_verified] if user_info.key?(:email_verified)
|
|
629
|
-
user = ctx.context.internal_adapter.update_user(user.fetch("id"), update) if update.any?
|
|
630
|
-
end
|
|
631
|
-
created = false
|
|
632
|
-
else
|
|
633
|
-
created = ctx.context.internal_adapter.create_user(
|
|
634
|
-
email: email,
|
|
635
|
-
name: user_info[:name] || email,
|
|
636
|
-
emailVerified: user_info.key?(:email_verified) ? user_info[:email_verified] : false,
|
|
637
|
-
image: user_info[:image]
|
|
638
|
-
)
|
|
639
|
-
ctx.context.internal_adapter.create_account(
|
|
640
|
-
accountId: account_id.empty? ? created.fetch("id") : account_id,
|
|
641
|
-
providerId: storage_provider_id,
|
|
642
|
-
userId: created.fetch("id")
|
|
643
|
-
)
|
|
644
|
-
user = created
|
|
645
|
-
created = true
|
|
646
|
-
end
|
|
647
|
-
sso_assign_organization_membership(ctx, provider, user, config)
|
|
648
|
-
{user: user, created: created}
|
|
649
|
-
end
|
|
650
|
-
|
|
651
|
-
def sso_saml_trusted_provider?(ctx, provider, email)
|
|
652
|
-
provider_id = provider.fetch("providerId")
|
|
653
|
-
linking = ctx.context.options.account[:account_linking] || {}
|
|
654
|
-
return false if linking[:enabled] == false
|
|
655
|
-
|
|
656
|
-
trusted = Array(linking[:trusted_providers]).map(&:to_s).include?(provider_id.to_s)
|
|
657
|
-
trusted || (provider["domainVerified"] && sso_email_domain_matches?(email, provider["domain"]))
|
|
658
|
-
end
|
|
659
|
-
|
|
660
|
-
def sso_validate_saml_response!(config, assertion, provider, ctx)
|
|
661
|
-
validator = config.dig(:saml, :validate_response)
|
|
662
|
-
return unless validator.respond_to?(:call)
|
|
663
|
-
return if validator.call(response: assertion, provider: provider, context: ctx)
|
|
664
|
-
|
|
665
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
|
|
666
|
-
end
|
|
667
|
-
|
|
668
|
-
def sso_validate_saml_config!(saml_config, plugin_config = {})
|
|
669
|
-
metadata = saml_config[:idp_metadata] || saml_config[:metadata] || saml_config[:idp_metadata_xml]
|
|
670
|
-
max_metadata_size = plugin_config.dig(:saml, :max_metadata_size) || SSO_DEFAULT_MAX_SAML_METADATA_SIZE
|
|
671
|
-
if metadata.to_s.bytesize > max_metadata_size
|
|
672
|
-
raise APIError.new("BAD_REQUEST", message: "IdP metadata exceeds maximum allowed size (#{max_metadata_size} bytes)")
|
|
673
|
-
end
|
|
674
|
-
|
|
675
|
-
if saml_config[:entry_point].to_s.empty? && saml_config[:single_sign_on_service].to_s.empty? && metadata.to_s.empty?
|
|
676
|
-
raise APIError.new("BAD_REQUEST", message: "SAML config must include entryPoint, singleSignOnService, or IdP metadata")
|
|
677
|
-
end
|
|
678
|
-
sso_validate_url!(saml_config[:entry_point], "SAML entryPoint must be a valid URL") unless saml_config[:entry_point].to_s.empty?
|
|
679
|
-
unless saml_config[:single_sign_on_service].to_s.empty?
|
|
680
|
-
sso_validate_url!(saml_config[:single_sign_on_service], "SAML singleLogoutService must be a valid URL")
|
|
681
|
-
end
|
|
682
|
-
|
|
683
|
-
sso_validate_saml_algorithms!(
|
|
684
|
-
metadata.to_s,
|
|
685
|
-
on_deprecated: plugin_config.dig(:saml, :algorithms, :on_deprecated) || saml_config[:on_deprecated_algorithm] || "warn",
|
|
686
|
-
allowed_signature_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_signature_algorithms) || saml_config[:allowed_signature_algorithms],
|
|
687
|
-
allowed_digest_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_digest_algorithms) || saml_config[:allowed_digest_algorithms],
|
|
688
|
-
allowed_key_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_key_encryption_algorithms) || saml_config[:allowed_key_encryption_algorithms],
|
|
689
|
-
allowed_data_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_data_encryption_algorithms) || saml_config[:allowed_data_encryption_algorithms]
|
|
690
|
-
)
|
|
691
|
-
end
|
|
692
|
-
|
|
693
|
-
def sso_sp_metadata_xml(ctx, provider, config = {})
|
|
694
|
-
provider_id = provider.fetch("providerId")
|
|
695
|
-
saml_config = normalize_hash(provider["samlConfig"] || {})
|
|
696
|
-
explicit_metadata = saml_config.dig(:sp_metadata, :metadata)
|
|
697
|
-
return explicit_metadata unless explicit_metadata.to_s.empty?
|
|
698
|
-
|
|
699
|
-
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)}"
|
|
700
|
-
acs_url = sso_saml_acs_url(ctx, provider)
|
|
701
|
-
authn_requests_signed = !!saml_config[:authn_requests_signed]
|
|
702
|
-
want_assertions_signed = saml_config.key?(:want_assertions_signed) ? !!saml_config[:want_assertions_signed] : true
|
|
703
|
-
name_id_format = saml_config[:identifier_format].to_s.empty? ? "" : "<NameIDFormat>#{saml_config[:identifier_format]}</NameIDFormat>"
|
|
704
|
-
slo = if config.dig(:saml, :enable_single_logout)
|
|
705
|
-
location = "#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}"
|
|
706
|
-
"<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}\" />"
|
|
707
|
-
end
|
|
708
|
-
|
|
709
|
-
"<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>"
|
|
710
|
-
end
|
|
711
|
-
|
|
712
|
-
def sso_saml_acs_url(ctx, provider)
|
|
713
|
-
provider_id = provider.fetch("providerId")
|
|
714
|
-
base_url = ctx.context.base_url
|
|
715
|
-
configured = normalize_hash(provider["samlConfig"] || {})[:callback_url].to_s
|
|
716
|
-
return configured if sso_saml_acs_url?(configured)
|
|
717
|
-
|
|
718
|
-
"#{base_url}/sso/saml2/sp/acs/#{URI.encode_www_form_component(provider_id)}"
|
|
719
|
-
end
|
|
720
|
-
|
|
721
|
-
def sso_saml_acs_url?(url)
|
|
722
|
-
return false if url.to_s.empty?
|
|
723
|
-
|
|
724
|
-
URI.parse(url.to_s).path.include?("/sso/saml2/sp/acs")
|
|
725
|
-
rescue
|
|
726
|
-
false
|
|
727
|
-
end
|
|
728
|
-
|
|
729
|
-
def sso_saml_idp_metadata(provider_or_config)
|
|
730
|
-
saml_config = if provider_or_config.respond_to?(:key?) && (provider_or_config.key?("samlConfig") || provider_or_config.key?(:samlConfig))
|
|
731
|
-
normalize_hash(provider_or_config["samlConfig"] || provider_or_config[:samlConfig] || {})
|
|
732
|
-
else
|
|
733
|
-
normalize_hash(provider_or_config || {})
|
|
734
|
-
end
|
|
735
|
-
idp_metadata = normalize_hash(saml_config[:idp_metadata] || {})
|
|
736
|
-
xml = idp_metadata[:metadata] || saml_config[:metadata] || saml_config[:idp_metadata_xml]
|
|
737
|
-
parsed = xml.to_s.strip.empty? ? {} : sso_parse_saml_metadata_xml(xml)
|
|
738
|
-
parsed[:entity_id] ||= idp_metadata[:entity_id] || idp_metadata[:entityID] || saml_config[:issuer]
|
|
739
|
-
parsed[:cert] ||= idp_metadata[:cert] || saml_config[:cert]
|
|
740
|
-
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?
|
|
741
|
-
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?
|
|
742
|
-
parsed
|
|
743
|
-
end
|
|
744
|
-
|
|
745
|
-
def sso_parse_saml_metadata_xml(xml)
|
|
746
|
-
doc = REXML::Document.new(xml.to_s)
|
|
747
|
-
root = doc.root
|
|
748
|
-
{
|
|
749
|
-
entity_id: root&.attributes&.[]("entityID"),
|
|
750
|
-
cert: sso_saml_normalize_certificate(sso_saml_metadata_first_text(doc, "X509Certificate")),
|
|
751
|
-
single_sign_on_service: sso_saml_metadata_services(doc, "SingleSignOnService"),
|
|
752
|
-
single_logout_service: sso_saml_metadata_services(doc, "SingleLogoutService")
|
|
753
|
-
}.compact
|
|
754
|
-
rescue
|
|
755
|
-
{}
|
|
756
|
-
end
|
|
757
|
-
|
|
758
|
-
def sso_saml_metadata_services(doc, element_name)
|
|
759
|
-
services = []
|
|
760
|
-
REXML::XPath.each(doc, "//*") do |element|
|
|
761
|
-
next unless element.name == element_name
|
|
762
|
-
|
|
763
|
-
services << {
|
|
764
|
-
binding: element.attributes["Binding"],
|
|
765
|
-
location: element.attributes["Location"]
|
|
766
|
-
}.compact
|
|
767
|
-
end
|
|
768
|
-
services
|
|
769
|
-
end
|
|
770
|
-
|
|
771
|
-
def sso_saml_metadata_first_text(doc, element_name)
|
|
772
|
-
REXML::XPath.each(doc, "//*") do |element|
|
|
773
|
-
return element.text.to_s.strip if element.name == element_name && !element.text.to_s.strip.empty?
|
|
774
|
-
end
|
|
775
|
-
nil
|
|
776
|
-
end
|
|
777
|
-
|
|
778
|
-
def sso_saml_metadata_services_from_config(value)
|
|
779
|
-
Array(value).filter_map do |entry|
|
|
780
|
-
data = normalize_hash(entry || {})
|
|
781
|
-
next if data[:location].to_s.empty?
|
|
782
|
-
|
|
783
|
-
{binding: data[:binding] || data[:Binding], location: data[:location] || data[:Location]}.compact
|
|
784
|
-
end
|
|
785
|
-
end
|
|
786
|
-
|
|
787
|
-
def sso_saml_preferred_service(services)
|
|
788
|
-
Array(services).find { |service| normalize_hash(service)[:binding].to_s.include?("HTTP-Redirect") } || Array(services).first
|
|
789
|
-
end
|
|
790
|
-
|
|
791
|
-
def sso_saml_normalize_certificate(value)
|
|
792
|
-
cert = value.to_s.strip
|
|
793
|
-
return nil if cert.empty?
|
|
794
|
-
return cert if cert.include?("BEGIN CERTIFICATE")
|
|
795
|
-
|
|
796
|
-
"-----BEGIN CERTIFICATE-----\n#{cert.scan(/.{1,64}/).join("\n")}\n-----END CERTIFICATE-----"
|
|
797
|
-
end
|
|
798
|
-
|
|
799
|
-
def sso_saml_callback_url(provider)
|
|
800
|
-
saml_config = normalize_hash(provider["samlConfig"] || {})
|
|
801
|
-
saml_config[:callback_url]
|
|
802
|
-
end
|
|
803
|
-
|
|
804
|
-
def sso_saml_logout_destination(provider)
|
|
805
|
-
saml_config = normalize_hash(provider["samlConfig"] || {})
|
|
806
|
-
direct = saml_config[:single_logout_service] ||
|
|
807
|
-
saml_config[:single_logout_service_url] ||
|
|
808
|
-
saml_config[:idp_slo_service_url] ||
|
|
809
|
-
saml_config[:logout_url]
|
|
810
|
-
return direct unless direct.to_s.empty?
|
|
811
|
-
|
|
812
|
-
service = sso_saml_preferred_service(sso_saml_idp_metadata(saml_config)[:single_logout_service])
|
|
813
|
-
normalize_hash(service || {})[:location]
|
|
814
|
-
end
|
|
815
|
-
|
|
816
|
-
def sso_store_saml_session(ctx, provider, assertion, session)
|
|
817
|
-
name_id = assertion[:name_id] || assertion[:nameid] || assertion[:email]
|
|
818
|
-
session_index = assertion[:session_index] || assertion[:sessionindex] || assertion[:id]
|
|
819
|
-
return if name_id.to_s.empty? || session_index.to_s.empty?
|
|
820
|
-
|
|
821
|
-
record = {
|
|
822
|
-
providerId: provider.fetch("providerId"),
|
|
823
|
-
sessionToken: session.fetch("token"),
|
|
824
|
-
userId: session.fetch("userId"),
|
|
825
|
-
nameId: name_id.to_s,
|
|
826
|
-
sessionIndex: session_index.to_s
|
|
827
|
-
}
|
|
828
|
-
expires_at = session["expiresAt"] || Time.now + (SSO_DEFAULT_ASSERTION_TTL_MS / 1000.0)
|
|
829
|
-
value = JSON.generate(record)
|
|
830
|
-
session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{name_id}"
|
|
831
|
-
ctx.context.internal_adapter.create_verification_value(
|
|
832
|
-
identifier: session_identifier,
|
|
833
|
-
value: value,
|
|
834
|
-
expiresAt: expires_at
|
|
835
|
-
)
|
|
836
|
-
ctx.context.internal_adapter.create_verification_value(
|
|
837
|
-
identifier: "#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session.fetch("token")}",
|
|
838
|
-
value: session_identifier,
|
|
839
|
-
expiresAt: expires_at
|
|
840
|
-
)
|
|
841
|
-
end
|
|
842
|
-
|
|
843
|
-
def sso_process_saml_logout_request(ctx, provider, raw_request)
|
|
844
|
-
data = sso_parse_saml_logout_request(raw_request)
|
|
845
|
-
return if data[:name_id].to_s.empty?
|
|
846
|
-
|
|
847
|
-
session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{data[:name_id]}"
|
|
848
|
-
verification = ctx.context.internal_adapter.find_verification_value(session_identifier)
|
|
849
|
-
return unless verification
|
|
850
|
-
|
|
851
|
-
record = JSON.parse(verification.fetch("value"))
|
|
852
|
-
session_token = record["sessionToken"]
|
|
853
|
-
session_index_matches = data[:session_index].to_s.empty? || record["sessionIndex"].to_s.empty? || data[:session_index].to_s == record["sessionIndex"].to_s
|
|
854
|
-
ctx.context.internal_adapter.delete_session(session_token) if session_token && session_index_matches
|
|
855
|
-
ctx.context.internal_adapter.delete_verification_by_identifier(session_identifier)
|
|
856
|
-
ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}") if session_token
|
|
857
|
-
rescue
|
|
858
|
-
nil
|
|
859
|
-
end
|
|
860
|
-
|
|
861
|
-
def sso_store_saml_logout_request(ctx, provider, request_id, config)
|
|
862
|
-
ttl_ms = (config.dig(:saml, :logout_request_ttl) || SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS).to_i
|
|
863
|
-
ctx.context.internal_adapter.create_verification_value(
|
|
864
|
-
identifier: "#{SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX}#{request_id}",
|
|
865
|
-
value: provider.fetch("providerId"),
|
|
866
|
-
expiresAt: Time.now + (ttl_ms / 1000.0)
|
|
867
|
-
)
|
|
868
|
-
end
|
|
869
|
-
|
|
870
|
-
def sso_process_saml_logout_response(ctx, raw_response)
|
|
871
|
-
data = sso_parse_saml_logout_response(raw_response)
|
|
872
|
-
status_code = data[:status_code]
|
|
873
|
-
if status_code && status_code != SSO_SAML_STATUS_SUCCESS
|
|
874
|
-
raise APIError.new("BAD_REQUEST", message: "Logout failed at IdP")
|
|
875
|
-
end
|
|
876
|
-
|
|
877
|
-
in_response_to = data[:in_response_to]
|
|
878
|
-
return if in_response_to.to_s.empty?
|
|
879
|
-
|
|
880
|
-
ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX}#{in_response_to}")
|
|
881
|
-
end
|
|
882
|
-
|
|
883
|
-
def sso_parse_saml_logout_request(raw_request)
|
|
884
|
-
xml = Base64.decode64(raw_request.to_s.gsub(/\s+/, ""))
|
|
885
|
-
{
|
|
886
|
-
name_id: xml[%r{<(?:\w+:)?NameID[^>]*>([^<]+)</(?:\w+:)?NameID>}, 1],
|
|
887
|
-
session_index: xml[%r{<(?:\w+:)?SessionIndex[^>]*>([^<]+)</(?:\w+:)?SessionIndex>}, 1]
|
|
888
|
-
}
|
|
889
|
-
rescue
|
|
890
|
-
{}
|
|
891
|
-
end
|
|
892
|
-
|
|
893
|
-
def sso_parse_saml_logout_response(raw_response)
|
|
894
|
-
xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
|
|
895
|
-
{
|
|
896
|
-
in_response_to: xml[/\bInResponseTo=['"]([^'"]+)['"]/, 1],
|
|
897
|
-
status_code: xml[/<(?:\w+:)?StatusCode\b[^>]*\bValue=['"]([^'"]+)['"]/, 1]
|
|
898
|
-
}
|
|
899
|
-
rescue
|
|
900
|
-
{}
|
|
901
|
-
end
|
|
902
|
-
|
|
903
|
-
def sso_safe_slo_redirect_url(ctx, url, provider_id)
|
|
904
|
-
app_origin = ctx.context.base_url
|
|
905
|
-
callback_path = URI.parse("#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}").path
|
|
906
|
-
value = url.to_s
|
|
907
|
-
return app_origin if value.empty?
|
|
908
|
-
|
|
909
|
-
if value.start_with?("/") && !value.start_with?("//")
|
|
910
|
-
parsed = URI.parse(value)
|
|
911
|
-
return app_origin if parsed.path == callback_path
|
|
912
|
-
return value
|
|
913
|
-
end
|
|
914
|
-
|
|
915
|
-
return app_origin unless ctx.context.trusted_origin?(value, allow_relative_paths: false)
|
|
916
|
-
|
|
917
|
-
parsed = URI.parse(value)
|
|
918
|
-
return app_origin if parsed.path == callback_path
|
|
919
|
-
|
|
920
|
-
value
|
|
921
|
-
rescue
|
|
922
|
-
app_origin
|
|
923
|
-
end
|
|
924
|
-
|
|
925
|
-
def sso_safe_saml_callback_url(ctx, url, provider_id)
|
|
926
|
-
app_origin = ctx.context.base_url
|
|
927
|
-
callback_path = URI.parse("#{ctx.context.base_url}/sso/saml2/callback/#{URI.encode_www_form_component(provider_id)}").path
|
|
928
|
-
acs_path = URI.parse("#{ctx.context.base_url}/sso/saml2/sp/acs/#{URI.encode_www_form_component(provider_id)}").path
|
|
929
|
-
value = url.to_s
|
|
930
|
-
return app_origin if value.empty?
|
|
931
|
-
|
|
932
|
-
if value.start_with?("/") && !value.start_with?("//")
|
|
933
|
-
parsed = URI.parse(value)
|
|
934
|
-
return app_origin if [callback_path, acs_path].include?(parsed.path)
|
|
935
|
-
return value
|
|
936
|
-
end
|
|
937
|
-
|
|
938
|
-
return app_origin unless ctx.context.trusted_origin?(value, allow_relative_paths: false)
|
|
939
|
-
|
|
940
|
-
parsed = URI.parse(value)
|
|
941
|
-
return app_origin if [callback_path, acs_path].include?(parsed.path)
|
|
942
|
-
|
|
943
|
-
value
|
|
944
|
-
rescue
|
|
945
|
-
app_origin
|
|
946
|
-
end
|
|
947
|
-
|
|
948
|
-
def sso_signed_saml_redirect_query(provider, query)
|
|
949
|
-
saml_config = normalize_hash(provider["samlConfig"] || {})
|
|
950
|
-
private_key = saml_config.dig(:sp_metadata, :private_key) || saml_config[:private_key] || saml_config[:sp_private_key]
|
|
951
|
-
raise APIError.new("BAD_REQUEST", message: "SAML Redirect signing requires privateKey") if private_key.to_s.empty?
|
|
952
|
-
|
|
953
|
-
sig_alg = saml_config[:signature_algorithm] ? sso_normalize_saml_signature_algorithm(saml_config[:signature_algorithm]) : XMLSecurity::Document::RSA_SHA256
|
|
954
|
-
signed = query.compact.merge(SigAlg: sig_alg)
|
|
955
|
-
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? }
|
|
956
|
-
signature_input = URI.encode_www_form(signed_payload)
|
|
957
|
-
signed[:Signature] = Base64.strict_encode64(OpenSSL::PKey.read(private_key).sign(sso_saml_signature_digest(sig_alg), signature_input))
|
|
958
|
-
signed
|
|
959
|
-
end
|
|
960
|
-
|
|
961
|
-
def sso_saml_signature_digest(signature_algorithm)
|
|
962
|
-
case signature_algorithm.to_s
|
|
963
|
-
when /sha512/i
|
|
964
|
-
OpenSSL::Digest.new("SHA512")
|
|
965
|
-
when /sha384/i
|
|
966
|
-
OpenSSL::Digest.new("SHA384")
|
|
967
|
-
when /sha1/i
|
|
968
|
-
OpenSSL::Digest.new("SHA1")
|
|
969
|
-
else
|
|
970
|
-
OpenSSL::Digest.new("SHA256")
|
|
971
|
-
end
|
|
972
|
-
end
|
|
973
|
-
|
|
974
|
-
def sso_saml_post_form(action, saml_param, saml_value, relay_state = nil)
|
|
975
|
-
relay_input = relay_state.to_s.empty? ? "" : "<input type=\"hidden\" name=\"RelayState\" value=\"#{CGI.escapeHTML(relay_state.to_s)}\" />"
|
|
976
|
-
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>"
|
|
977
|
-
[200, {"content-type" => "text/html"}, [html]]
|
|
978
|
-
end
|
|
979
|
-
|
|
980
|
-
def sso_assign_organization_membership(ctx, provider, user, config)
|
|
981
|
-
organization_id = provider["organizationId"]
|
|
982
|
-
return if organization_id.to_s.empty?
|
|
983
|
-
return if config.dig(:organization_provisioning, :disabled)
|
|
984
|
-
return unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
|
|
985
|
-
return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user.fetch("id")}])
|
|
986
|
-
|
|
987
|
-
role = if config.dig(:organization_provisioning, :get_role).respond_to?(:call)
|
|
988
|
-
config.dig(:organization_provisioning, :get_role).call(user: user, userInfo: {}, provider: provider)
|
|
989
|
-
else
|
|
990
|
-
config.dig(:organization_provisioning, :default_role) || config.dig(:organization_provisioning, :role) || "member"
|
|
991
|
-
end
|
|
992
|
-
ctx.context.adapter.create(model: "member", data: {organizationId: organization_id, userId: user.fetch("id"), role: role, createdAt: Time.now})
|
|
993
|
-
end
|
|
994
|
-
|
|
995
|
-
def sso_parse_saml_response(value, config = {}, provider = nil, ctx = nil)
|
|
996
|
-
parser = config.dig(:saml, :parse_response)
|
|
997
|
-
if parser.respond_to?(:call)
|
|
998
|
-
sso_validate_single_saml_assertion!(value) if sso_base64_xml?(value)
|
|
999
|
-
parsed = parser.call(raw_response: value.to_s, provider: provider, context: ctx)
|
|
1000
|
-
return normalize_hash(parsed)
|
|
1001
|
-
end
|
|
1002
|
-
|
|
1003
|
-
JSON.parse(Base64.decode64(value.to_s), symbolize_names: true)
|
|
1004
|
-
rescue APIError
|
|
1005
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
|
|
1006
|
-
rescue
|
|
1007
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
|
|
1008
|
-
end
|
|
1009
|
-
|
|
1010
|
-
def sso_validate_single_saml_assertion!(saml_response)
|
|
1011
|
-
xml = Base64.decode64(saml_response.to_s)
|
|
1012
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid base64-encoded SAML response") unless xml.include?("<")
|
|
1013
|
-
|
|
1014
|
-
assertions = xml.scan(/<(?:\w+:)?Assertion(?:\s|>|\/)/).length
|
|
1015
|
-
encrypted_assertions = xml.scan(/<(?:\w+:)?EncryptedAssertion(?:\s|>|\/)/).length
|
|
1016
|
-
total = assertions + encrypted_assertions
|
|
1017
|
-
raise APIError.new("BAD_REQUEST", message: "SAML response contains no assertions") if total.zero?
|
|
1018
|
-
if total > 1
|
|
1019
|
-
raise APIError.new("BAD_REQUEST", message: "SAML response contains #{total} assertions, expected exactly 1")
|
|
1020
|
-
end
|
|
1021
|
-
|
|
1022
|
-
true
|
|
1023
|
-
rescue APIError
|
|
1024
|
-
raise
|
|
1025
|
-
rescue
|
|
1026
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid base64-encoded SAML response")
|
|
1027
|
-
end
|
|
1028
|
-
|
|
1029
|
-
def sso_validate_saml_timestamp!(conditions, config = {}, now: Time.now.utc)
|
|
1030
|
-
conditions = normalize_hash(conditions || {})
|
|
1031
|
-
not_before = conditions[:not_before] || conditions[:notBefore]
|
|
1032
|
-
not_on_or_after = conditions[:not_on_or_after] || conditions[:notOnOrAfter]
|
|
1033
|
-
if not_before.to_s.empty? && not_on_or_after.to_s.empty?
|
|
1034
|
-
raise APIError.new("BAD_REQUEST", message: "SAML assertion missing required timestamp conditions") if config.dig(:saml, :require_timestamps)
|
|
1035
|
-
|
|
1036
|
-
return true
|
|
1037
|
-
end
|
|
1038
|
-
|
|
1039
|
-
clock_skew_seconds = ((config.dig(:saml, :clock_skew) || SSO_DEFAULT_CLOCK_SKEW_MS).to_f / 1000.0)
|
|
1040
|
-
parsed_not_before = sso_parse_saml_timestamp(not_before, "SAML assertion has invalid NotBefore timestamp") unless not_before.to_s.empty?
|
|
1041
|
-
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?
|
|
1042
|
-
|
|
1043
|
-
raise APIError.new("BAD_REQUEST", message: "SAML assertion is not yet valid") if parsed_not_before && now < (parsed_not_before - clock_skew_seconds)
|
|
1044
|
-
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)
|
|
1045
|
-
|
|
1046
|
-
true
|
|
1047
|
-
end
|
|
1048
|
-
|
|
1049
|
-
def sso_parse_saml_timestamp(value, error_message)
|
|
1050
|
-
Time.parse(value.to_s).utc
|
|
1051
|
-
rescue
|
|
1052
|
-
raise APIError.new("BAD_REQUEST", message: error_message)
|
|
1053
|
-
end
|
|
1054
|
-
|
|
1055
|
-
def sso_saml_timestamp_conditions(assertion)
|
|
1056
|
-
assertion = normalize_hash(assertion || {})
|
|
1057
|
-
conditions = normalize_hash(assertion[:conditions] || {})
|
|
1058
|
-
conditions[:not_before] ||= assertion[:not_before] || assertion[:notBefore]
|
|
1059
|
-
conditions[:not_on_or_after] ||= assertion[:not_on_or_after] || assertion[:notOnOrAfter]
|
|
1060
|
-
conditions
|
|
1061
|
-
end
|
|
1062
|
-
|
|
1063
|
-
def sso_base64_xml?(value)
|
|
1064
|
-
Base64.decode64(value.to_s).lstrip.start_with?("<")
|
|
1065
|
-
rescue
|
|
1066
|
-
false
|
|
1067
|
-
end
|
|
1068
|
-
|
|
1069
|
-
def sso_validate_saml_algorithms!(xml, options = {})
|
|
1070
|
-
on_deprecated = (options[:on_deprecated] || "warn").to_s
|
|
1071
|
-
signature_algorithms = xml.to_s.scan(/SignatureMethod[^>]+Algorithm=["']([^"']+)["']/).flatten.map { |algorithm| sso_normalize_saml_signature_algorithm(algorithm) }
|
|
1072
|
-
digest_algorithms = xml.to_s.scan(/DigestMethod[^>]+Algorithm=["']([^"']+)["']/).flatten.map { |algorithm| sso_normalize_saml_digest_algorithm(algorithm) }
|
|
1073
|
-
key_encryption_algorithms = xml.to_s.scan(/<[^\/>]*EncryptedKey\b[\s\S]*?EncryptionMethod[^>]+Algorithm=["']([^"']+)["']/).flatten
|
|
1074
|
-
data_encryption_algorithms = xml.to_s.scan(/<[^\/>]*EncryptedData\b[\s\S]*?EncryptionMethod[^>]+Algorithm=["']([^"']+)["']/).flatten
|
|
1075
|
-
|
|
1076
|
-
sso_validate_saml_algorithm_group!(
|
|
1077
|
-
signature_algorithms,
|
|
1078
|
-
allowed: options[:allowed_signature_algorithms]&.map { |algorithm| sso_normalize_saml_signature_algorithm(algorithm) },
|
|
1079
|
-
secure: SSO_SAML_SECURE_SIGNATURE_ALGORITHMS,
|
|
1080
|
-
deprecated: ["http://www.w3.org/2000/09/xmldsig#rsa-sha1"],
|
|
1081
|
-
on_deprecated: on_deprecated,
|
|
1082
|
-
label: "signature"
|
|
1083
|
-
)
|
|
1084
|
-
sso_validate_saml_algorithm_group!(
|
|
1085
|
-
digest_algorithms,
|
|
1086
|
-
allowed: options[:allowed_digest_algorithms]&.map { |algorithm| sso_normalize_saml_digest_algorithm(algorithm) },
|
|
1087
|
-
secure: SSO_SAML_SECURE_DIGEST_ALGORITHMS,
|
|
1088
|
-
deprecated: ["http://www.w3.org/2000/09/xmldsig#sha1"],
|
|
1089
|
-
on_deprecated: on_deprecated,
|
|
1090
|
-
label: "digest"
|
|
1091
|
-
)
|
|
1092
|
-
sso_validate_saml_algorithm_group!(
|
|
1093
|
-
key_encryption_algorithms,
|
|
1094
|
-
allowed: options[:allowed_key_encryption_algorithms],
|
|
1095
|
-
secure: SSO_SAML_SECURE_KEY_ENCRYPTION_ALGORITHMS,
|
|
1096
|
-
deprecated: ["http://www.w3.org/2001/04/xmlenc#rsa-1_5"],
|
|
1097
|
-
on_deprecated: on_deprecated,
|
|
1098
|
-
label: "key encryption"
|
|
1099
|
-
)
|
|
1100
|
-
sso_validate_saml_algorithm_group!(
|
|
1101
|
-
data_encryption_algorithms,
|
|
1102
|
-
allowed: options[:allowed_data_encryption_algorithms],
|
|
1103
|
-
secure: SSO_SAML_SECURE_DATA_ENCRYPTION_ALGORITHMS,
|
|
1104
|
-
deprecated: ["http://www.w3.org/2001/04/xmlenc#tripledes-cbc"],
|
|
1105
|
-
on_deprecated: on_deprecated,
|
|
1106
|
-
label: "data encryption"
|
|
1107
|
-
)
|
|
1108
|
-
|
|
1109
|
-
true
|
|
1110
|
-
end
|
|
1111
|
-
|
|
1112
|
-
def sso_normalize_saml_signature_algorithm(algorithm)
|
|
1113
|
-
SSO_SAML_SIGNATURE_ALGORITHMS.fetch(algorithm.to_s.downcase, algorithm.to_s)
|
|
1114
|
-
end
|
|
1115
|
-
|
|
1116
|
-
def sso_normalize_saml_digest_algorithm(algorithm)
|
|
1117
|
-
SSO_SAML_DIGEST_ALGORITHMS.fetch(algorithm.to_s.downcase, algorithm.to_s)
|
|
1118
|
-
end
|
|
1119
|
-
|
|
1120
|
-
def sso_validate_saml_algorithm_group!(algorithms, allowed:, secure:, deprecated:, on_deprecated:, label:)
|
|
1121
|
-
algorithms.each do |algorithm|
|
|
1122
|
-
if allowed
|
|
1123
|
-
next if allowed.include?(algorithm)
|
|
1124
|
-
|
|
1125
|
-
raise APIError.new("BAD_REQUEST", message: "SAML #{label} algorithm not in allow-list: #{algorithm}")
|
|
1126
|
-
end
|
|
1127
|
-
|
|
1128
|
-
if deprecated.include?(algorithm)
|
|
1129
|
-
raise APIError.new("BAD_REQUEST", message: "SAML response uses deprecated #{label} algorithm: #{algorithm}") if on_deprecated == "reject"
|
|
1130
|
-
next
|
|
1131
|
-
end
|
|
1132
|
-
next if secure.include?(algorithm)
|
|
1133
|
-
|
|
1134
|
-
raise APIError.new("BAD_REQUEST", message: "SAML #{label} algorithm not recognized: #{algorithm}")
|
|
1135
|
-
end
|
|
1136
|
-
end
|
|
1137
|
-
|
|
1138
|
-
def sso_generate_saml_relay_state(ctx, state_data)
|
|
1139
|
-
ttl_ms = 10 * 60 * 1000
|
|
1140
|
-
relay_state = BetterAuth::Crypto.random_string(32)
|
|
1141
|
-
now_ms = (Time.now.to_f * 1000).to_i
|
|
1142
|
-
stored = state_data.each_with_object({}) { |(key, value), result| result[key.to_s] = value }.merge(
|
|
1143
|
-
"codeVerifier" => BetterAuth::Crypto.random_string(128),
|
|
1144
|
-
"expiresAt" => now_ms + ttl_ms
|
|
1145
|
-
)
|
|
1146
|
-
ctx.context.internal_adapter.create_verification_value(
|
|
1147
|
-
identifier: "#{SSO_SAML_RELAY_STATE_KEY_PREFIX}#{relay_state}",
|
|
1148
|
-
value: JSON.generate(stored),
|
|
1149
|
-
expiresAt: Time.at((now_ms + ttl_ms) / 1000.0)
|
|
1150
|
-
)
|
|
1151
|
-
ctx.set_signed_cookie("relay_state", relay_state, ctx.context.secret, path: "/", max_age: ttl_ms / 1000, http_only: true, same_site: "lax")
|
|
1152
|
-
relay_state
|
|
1153
|
-
end
|
|
1154
|
-
|
|
1155
|
-
def sso_parse_saml_relay_state(ctx, relay_state)
|
|
1156
|
-
state = sso_verify_state(relay_state, ctx.context.secret)
|
|
1157
|
-
return state if state
|
|
1158
|
-
|
|
1159
|
-
verification = ctx.context.internal_adapter.find_verification_value("#{SSO_SAML_RELAY_STATE_KEY_PREFIX}#{relay_state}")
|
|
1160
|
-
return nil unless verification
|
|
1161
|
-
return nil unless sso_future_time?(verification.fetch("expiresAt"))
|
|
1162
|
-
|
|
1163
|
-
parsed = JSON.parse(verification.fetch("value"))
|
|
1164
|
-
return nil if parsed["expiresAt"].to_i <= (Time.now.to_f * 1000).to_i
|
|
1165
|
-
|
|
1166
|
-
parsed
|
|
1167
|
-
rescue
|
|
1168
|
-
nil
|
|
1169
|
-
end
|
|
1170
|
-
|
|
1171
|
-
def sso_verify_state(value, secret)
|
|
1172
|
-
BetterAuth::Crypto.verify_jwt(value.to_s, secret)
|
|
1173
|
-
rescue
|
|
1174
|
-
nil
|
|
1175
|
-
end
|
|
1176
|
-
|
|
1177
|
-
def sso_oidc_authorization_url(provider, ctx, state, plugin_config = {}, body = {})
|
|
1178
|
-
config = normalize_hash(provider["oidcConfig"] || {})
|
|
1179
|
-
endpoint = config[:authorization_endpoint] || config[:authorization_url]
|
|
1180
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid OIDC configuration. Authorization URL not found.") if endpoint.to_s.empty?
|
|
1181
|
-
|
|
1182
|
-
scopes = Array(body[:scopes] || config[:scopes] || config[:scope] || ["openid", "email", "profile", "offline_access"])
|
|
1183
|
-
query = {
|
|
1184
|
-
client_id: config[:client_id],
|
|
1185
|
-
response_type: "code",
|
|
1186
|
-
redirect_uri: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
|
|
1187
|
-
scope: scopes.join(" "),
|
|
1188
|
-
state: state
|
|
1189
|
-
}.compact
|
|
1190
|
-
login_hint = body[:login_hint] || body[:email]
|
|
1191
|
-
query[:login_hint] = login_hint if login_hint
|
|
1192
|
-
code_verifier = sso_decode_state(state, ctx.context.secret)&.fetch("codeVerifier", nil)
|
|
1193
|
-
if code_verifier
|
|
1194
|
-
query[:code_challenge] = sso_base64_urlsafe(OpenSSL::Digest::SHA256.digest(code_verifier))
|
|
1195
|
-
query[:code_challenge_method] = "S256"
|
|
1196
|
-
end
|
|
1197
|
-
"#{endpoint}?#{URI.encode_www_form(query)}"
|
|
1198
|
-
end
|
|
1199
|
-
|
|
1200
|
-
def sso_saml_authorization_url(provider, relay_state, ctx = nil, config = {})
|
|
1201
|
-
auth_request_url = config.dig(:saml, :auth_request_url)
|
|
1202
|
-
if auth_request_url.respond_to?(:call)
|
|
1203
|
-
return auth_request_url.call(provider: provider, relay_state: relay_state, context: ctx)
|
|
1204
|
-
end
|
|
1205
|
-
|
|
1206
|
-
config = normalize_hash(provider["samlConfig"] || {})
|
|
1207
|
-
metadata = sso_saml_idp_metadata(config)
|
|
1208
|
-
entry_point = config[:entry_point] || normalize_hash(sso_saml_preferred_service(metadata[:single_sign_on_service]) || {})[:location]
|
|
1209
|
-
query = {
|
|
1210
|
-
SAMLRequest: Base64.strict_encode64(JSON.generate({providerId: provider.fetch("providerId")})),
|
|
1211
|
-
RelayState: relay_state
|
|
1212
|
-
}
|
|
1213
|
-
"#{entry_point}?#{URI.encode_www_form(query)}"
|
|
1214
|
-
end
|
|
1215
|
-
|
|
1216
|
-
def sso_store_saml_authn_request(ctx, provider, url, config)
|
|
1217
|
-
return if config.dig(:saml, :enable_in_response_to_validation) == false
|
|
1218
|
-
|
|
1219
|
-
request_id = sso_extract_saml_request_id(url)
|
|
1220
|
-
return if request_id.to_s.empty?
|
|
1221
|
-
|
|
1222
|
-
ttl_ms = (config.dig(:saml, :request_ttl) || SSO_DEFAULT_AUTHN_REQUEST_TTL_MS).to_i
|
|
1223
|
-
now_ms = (Time.now.to_f * 1000).to_i
|
|
1224
|
-
expires_at_ms = now_ms + ttl_ms
|
|
1225
|
-
record = {
|
|
1226
|
-
id: request_id,
|
|
1227
|
-
providerId: provider.fetch("providerId"),
|
|
1228
|
-
createdAt: now_ms,
|
|
1229
|
-
expiresAt: expires_at_ms
|
|
1230
|
-
}
|
|
1231
|
-
ctx.context.internal_adapter.create_verification_value(
|
|
1232
|
-
identifier: "#{SSO_SAML_AUTHN_REQUEST_KEY_PREFIX}#{request_id}",
|
|
1233
|
-
value: JSON.generate(record),
|
|
1234
|
-
expiresAt: Time.at(expires_at_ms / 1000.0)
|
|
1235
|
-
)
|
|
1236
|
-
end
|
|
1237
|
-
|
|
1238
|
-
def sso_extract_saml_request_id(url)
|
|
1239
|
-
query = URI.decode_www_form(URI.parse(url.to_s).query.to_s).to_h
|
|
1240
|
-
encoded = query["SAMLRequest"]
|
|
1241
|
-
return nil if encoded.to_s.empty?
|
|
1242
|
-
|
|
1243
|
-
xml = Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(Base64.decode64(encoded))
|
|
1244
|
-
xml[/\bID=['"]([^'"]+)['"]/, 1]
|
|
1245
|
-
rescue
|
|
1246
|
-
nil
|
|
1247
|
-
end
|
|
1248
|
-
|
|
1249
|
-
def sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
|
|
1250
|
-
return nil if config.dig(:saml, :enable_in_response_to_validation) == false
|
|
1251
|
-
|
|
1252
|
-
in_response_to = sso_extract_saml_in_response_to(raw_response)
|
|
1253
|
-
if in_response_to && !in_response_to.empty?
|
|
1254
|
-
identifier = "#{SSO_SAML_AUTHN_REQUEST_KEY_PREFIX}#{in_response_to}"
|
|
1255
|
-
verification = ctx.context.internal_adapter.find_verification_value(identifier)
|
|
1256
|
-
record = sso_parse_saml_authn_request_record(verification&.fetch("value", nil))
|
|
1257
|
-
if !record || record["expiresAt"].to_i < (Time.now.to_f * 1000).to_i
|
|
1258
|
-
return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Unknown or expired request ID"))
|
|
1259
|
-
end
|
|
1260
|
-
|
|
1261
|
-
if record["providerId"] != provider.fetch("providerId")
|
|
1262
|
-
ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
|
|
1263
|
-
return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Provider mismatch"))
|
|
1264
|
-
end
|
|
1265
|
-
|
|
1266
|
-
ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
|
|
1267
|
-
elsif config.dig(:saml, :allow_idp_initiated) == false
|
|
1268
|
-
return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "unsolicited_response", "IdP-initiated SSO not allowed"))
|
|
1269
|
-
end
|
|
1270
|
-
|
|
1271
|
-
nil
|
|
1272
|
-
end
|
|
1273
|
-
|
|
1274
|
-
def sso_parse_saml_authn_request_record(value)
|
|
1275
|
-
JSON.parse(value.to_s)
|
|
1276
|
-
rescue
|
|
1277
|
-
nil
|
|
1278
|
-
end
|
|
1279
|
-
|
|
1280
|
-
def sso_saml_assertion_replay_expires_at(assertion, config = {})
|
|
1281
|
-
timestamp = sso_saml_timestamp_conditions(assertion)[:not_on_or_after]
|
|
1282
|
-
parsed = Time.parse(timestamp.to_s) if timestamp
|
|
1283
|
-
clock_skew_seconds = ((config.dig(:saml, :clock_skew) || SSO_DEFAULT_CLOCK_SKEW_MS).to_f / 1000.0)
|
|
1284
|
-
return parsed + clock_skew_seconds if parsed && parsed + clock_skew_seconds > Time.now
|
|
1285
|
-
|
|
1286
|
-
ttl_ms = (config.dig(:saml, :assertion_ttl) || SSO_DEFAULT_ASSERTION_TTL_MS).to_i
|
|
1287
|
-
Time.now + (ttl_ms / 1000.0)
|
|
1288
|
-
rescue
|
|
1289
|
-
Time.now + (SSO_DEFAULT_ASSERTION_TTL_MS / 1000.0)
|
|
1290
|
-
end
|
|
1291
|
-
|
|
1292
|
-
def sso_extract_saml_in_response_to(raw_response)
|
|
1293
|
-
xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
|
|
1294
|
-
xml[/\bInResponseTo=['"]([^'"]+)['"]/, 1]
|
|
1295
|
-
rescue
|
|
1296
|
-
nil
|
|
1297
|
-
end
|
|
1298
|
-
|
|
1299
|
-
def sso_select_provider(ctx, body, config = {})
|
|
1300
|
-
provider_id = body[:provider_id].to_s
|
|
1301
|
-
issuer = body[:issuer].to_s
|
|
1302
|
-
organization_slug = body[:organization_slug].to_s
|
|
1303
|
-
domain = (body[:domain] || body[:email].to_s.split("@").last).to_s.downcase
|
|
1304
|
-
if config[:default_sso]
|
|
1305
|
-
provider = sso_default_provider(config, provider_id: provider_id, domain: domain)
|
|
1306
|
-
return provider if provider
|
|
1307
|
-
end
|
|
1308
|
-
|
|
1309
|
-
providers = ctx.context.adapter.find_many(model: "ssoProvider")
|
|
1310
|
-
provider = if !provider_id.empty?
|
|
1311
|
-
providers.find { |entry| entry["providerId"] == provider_id }
|
|
1312
|
-
elsif !issuer.empty?
|
|
1313
|
-
providers.find { |entry| entry["issuer"] == issuer }
|
|
1314
|
-
elsif !organization_slug.empty?
|
|
1315
|
-
organization = ctx.context.adapter.find_one(model: "organization", where: [{field: "slug", value: organization_slug}])
|
|
1316
|
-
providers.find { |entry| entry["organizationId"] == organization&.fetch("id", nil) }
|
|
1317
|
-
elsif !domain.empty?
|
|
1318
|
-
providers.find { |entry| entry["domain"].to_s.downcase == domain } ||
|
|
1319
|
-
providers.find { |entry| sso_email_domain_matches?(domain, entry["domain"]) }
|
|
1320
|
-
end
|
|
1321
|
-
raise APIError.new("NOT_FOUND", message: SSO_ERROR_CODES.fetch("PROVIDER_NOT_FOUND")) unless provider
|
|
1322
|
-
|
|
1323
|
-
provider
|
|
1324
|
-
end
|
|
1325
|
-
|
|
1326
|
-
def sso_callback_provider(ctx, config, provider_id)
|
|
1327
|
-
if config[:default_sso]
|
|
1328
|
-
provider = sso_default_provider(config, provider_id: provider_id.to_s, domain: "")
|
|
1329
|
-
return provider if provider
|
|
1330
|
-
end
|
|
1331
|
-
|
|
1332
|
-
ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id.to_s}])
|
|
1333
|
-
end
|
|
1334
|
-
|
|
1335
|
-
def sso_oidc_tokens(ctx, provider, oidc_config, state, plugin_config)
|
|
1336
|
-
token_callback = oidc_config[:get_token]
|
|
1337
|
-
if token_callback.respond_to?(:call)
|
|
1338
|
-
return normalize_hash(token_callback.call(
|
|
1339
|
-
code: ctx.query[:code] || ctx.query["code"],
|
|
1340
|
-
codeVerifier: state["codeVerifier"],
|
|
1341
|
-
redirectURI: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
|
|
1342
|
-
provider: provider,
|
|
1343
|
-
context: ctx
|
|
1344
|
-
))
|
|
1345
|
-
end
|
|
1346
|
-
|
|
1347
|
-
token_endpoint = oidc_config[:token_endpoint]
|
|
1348
|
-
return nil if token_endpoint.to_s.empty?
|
|
1349
|
-
|
|
1350
|
-
sso_exchange_oidc_code(
|
|
1351
|
-
token_endpoint: token_endpoint,
|
|
1352
|
-
code: ctx.query[:code] || ctx.query["code"],
|
|
1353
|
-
code_verifier: state["codeVerifier"],
|
|
1354
|
-
redirect_uri: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
|
|
1355
|
-
client_id: oidc_config[:client_id],
|
|
1356
|
-
client_secret: oidc_config[:client_secret],
|
|
1357
|
-
authentication: oidc_config[:token_endpoint_authentication]
|
|
1358
|
-
)
|
|
1359
|
-
rescue
|
|
1360
|
-
nil
|
|
1361
|
-
end
|
|
1362
|
-
|
|
1363
|
-
def sso_exchange_oidc_code(token_endpoint:, code:, code_verifier:, redirect_uri:, client_id:, client_secret:, authentication:)
|
|
1364
|
-
uri = URI(token_endpoint.to_s)
|
|
1365
|
-
request = Net::HTTP::Post.new(uri)
|
|
1366
|
-
form = {
|
|
1367
|
-
grant_type: "authorization_code",
|
|
1368
|
-
code: code,
|
|
1369
|
-
redirect_uri: redirect_uri,
|
|
1370
|
-
client_id: client_id,
|
|
1371
|
-
code_verifier: code_verifier
|
|
1372
|
-
}.compact
|
|
1373
|
-
if authentication.to_s == "client_secret_post"
|
|
1374
|
-
form[:client_secret] = client_secret
|
|
1375
|
-
elsif client_secret.to_s != ""
|
|
1376
|
-
request.basic_auth(client_id.to_s, client_secret.to_s)
|
|
1377
|
-
end
|
|
1378
|
-
request.set_form_data(form)
|
|
1379
|
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
|
|
1380
|
-
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
1381
|
-
|
|
1382
|
-
normalize_hash(JSON.parse(response.body))
|
|
1383
|
-
end
|
|
1384
|
-
|
|
1385
|
-
def sso_oidc_user_info(ctx, oidc_config, tokens, plugin_config)
|
|
1386
|
-
user_callback = oidc_config[:get_user_info]
|
|
1387
|
-
raw = if user_callback.respond_to?(:call)
|
|
1388
|
-
user_callback.call(tokens)
|
|
1389
|
-
elsif oidc_config[:user_info_endpoint]
|
|
1390
|
-
sso_fetch_oidc_user_info(oidc_config[:user_info_endpoint], tokens[:access_token])
|
|
1391
|
-
elsif tokens[:id_token]
|
|
1392
|
-
return {_sso_error: "jwks_endpoint_not_found"} if oidc_config[:jwks_endpoint].to_s.empty?
|
|
1393
|
-
|
|
1394
|
-
sso_validate_oidc_id_token(
|
|
1395
|
-
tokens[:id_token],
|
|
1396
|
-
jwks_endpoint: oidc_config[:jwks_endpoint],
|
|
1397
|
-
audience: oidc_config[:client_id],
|
|
1398
|
-
issuer: oidc_config[:issuer],
|
|
1399
|
-
fetch: plugin_config[:oidc_jwks_fetch]
|
|
1400
|
-
) || {_sso_error: "token_not_verified"}
|
|
1401
|
-
else
|
|
1402
|
-
{}
|
|
1403
|
-
end
|
|
1404
|
-
raw = normalize_hash(raw || {})
|
|
1405
|
-
return raw if raw[:_sso_error]
|
|
1406
|
-
|
|
1407
|
-
mapping = normalize_hash(oidc_config[:mapping] || {})
|
|
1408
|
-
extra_fields = normalize_hash(mapping[:extra_fields] || {}).each_with_object({}) do |(target, source), result|
|
|
1409
|
-
result[target] = raw[normalize_key(source)] || raw[source.to_s]
|
|
1410
|
-
end
|
|
1411
|
-
extra_fields.merge(
|
|
1412
|
-
id: raw[normalize_key(mapping[:id] || "sub")] || raw[:id],
|
|
1413
|
-
email: raw[normalize_key(mapping[:email] || "email")],
|
|
1414
|
-
email_verified: plugin_config[:trust_email_verified] ? raw[normalize_key(mapping[:email_verified] || "email_verified")] : false,
|
|
1415
|
-
name: raw[normalize_key(mapping[:name] || "name")],
|
|
1416
|
-
image: raw[normalize_key(mapping[:image] || "picture")]
|
|
1417
|
-
)
|
|
1418
|
-
end
|
|
1419
|
-
|
|
1420
|
-
def sso_fetch_oidc_user_info(endpoint, access_token)
|
|
1421
|
-
uri = URI(endpoint.to_s)
|
|
1422
|
-
request = Net::HTTP::Get.new(uri)
|
|
1423
|
-
request["authorization"] = "Bearer #{access_token}"
|
|
1424
|
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
|
|
1425
|
-
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
1426
|
-
|
|
1427
|
-
JSON.parse(response.body)
|
|
1428
|
-
rescue
|
|
1429
|
-
{}
|
|
1430
|
-
end
|
|
1431
|
-
|
|
1432
|
-
def sso_validate_oidc_id_token(token, jwks_endpoint:, audience:, issuer:, fetch: nil)
|
|
1433
|
-
jwks = sso_fetch_oidc_jwks(jwks_endpoint, fetch: fetch)
|
|
1434
|
-
payload, = ::JWT.decode(
|
|
1435
|
-
token.to_s,
|
|
1436
|
-
nil,
|
|
1437
|
-
true,
|
|
1438
|
-
algorithms: %w[RS256 RS384 RS512 ES256 ES384 ES512],
|
|
1439
|
-
jwks: jwks,
|
|
1440
|
-
aud: audience,
|
|
1441
|
-
verify_aud: true,
|
|
1442
|
-
iss: issuer,
|
|
1443
|
-
verify_iss: true
|
|
1444
|
-
)
|
|
1445
|
-
payload
|
|
1446
|
-
rescue
|
|
1447
|
-
nil
|
|
1448
|
-
end
|
|
1449
|
-
|
|
1450
|
-
def sso_fetch_oidc_jwks(jwks_endpoint, fetch: nil)
|
|
1451
|
-
if fetch.respond_to?(:call)
|
|
1452
|
-
return normalize_hash(fetch.call(jwks_endpoint))
|
|
1453
|
-
end
|
|
1454
|
-
|
|
1455
|
-
uri = URI(jwks_endpoint.to_s)
|
|
1456
|
-
response = Net::HTTP.get_response(uri)
|
|
1457
|
-
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
1458
|
-
|
|
1459
|
-
normalize_hash(JSON.parse(response.body))
|
|
1460
|
-
rescue
|
|
1461
|
-
{}
|
|
1462
|
-
end
|
|
1463
|
-
|
|
1464
|
-
def sso_decode_jwt_payload(token)
|
|
1465
|
-
payload = token.to_s.split(".")[1]
|
|
1466
|
-
return {} unless payload
|
|
1467
|
-
|
|
1468
|
-
JSON.parse(Base64.urlsafe_decode64(payload.ljust((payload.length + 3) & ~3, "=")))
|
|
1469
|
-
rescue
|
|
1470
|
-
{}
|
|
1471
|
-
end
|
|
1472
|
-
|
|
1473
|
-
def sso_append_error(url, error, description = nil)
|
|
1474
|
-
separator = url.to_s.include?("?") ? "&" : "?"
|
|
1475
|
-
query = {error: error, error_description: description}.compact
|
|
1476
|
-
"#{url}#{separator}#{URI.encode_www_form(query)}"
|
|
1477
|
-
end
|
|
1478
|
-
|
|
1479
|
-
def sso_default_provider(config, provider_id:, domain:)
|
|
1480
|
-
Array(config[:default_sso]).each do |raw_provider|
|
|
1481
|
-
default_provider = normalize_hash(raw_provider)
|
|
1482
|
-
next if !provider_id.empty? && default_provider[:provider_id].to_s != provider_id
|
|
1483
|
-
next if provider_id.empty? && default_provider[:domain].to_s.downcase != domain
|
|
1484
|
-
|
|
1485
|
-
oidc_config = default_provider[:oidc_config] ? sso_storage_config(default_provider[:oidc_config]) : nil
|
|
1486
|
-
saml_config = default_provider[:saml_config] ? sso_storage_config(default_provider[:saml_config]) : nil
|
|
1487
|
-
return {
|
|
1488
|
-
"issuer" => default_provider[:issuer] || default_provider.dig(:oidc_config, :issuer) || default_provider.dig(:saml_config, :issuer) || "",
|
|
1489
|
-
"providerId" => default_provider.fetch(:provider_id),
|
|
1490
|
-
"userId" => "default",
|
|
1491
|
-
"domain" => default_provider[:domain],
|
|
1492
|
-
"domainVerified" => true,
|
|
1493
|
-
"oidcConfig" => oidc_config,
|
|
1494
|
-
"samlConfig" => saml_config
|
|
1495
|
-
}.compact
|
|
1496
|
-
end
|
|
1497
|
-
nil
|
|
1498
|
-
end
|
|
1499
|
-
|
|
1500
|
-
def sso_oidc_pkce_state(provider)
|
|
1501
|
-
return {} unless normalize_hash(provider["oidcConfig"] || {})[:pkce]
|
|
1502
|
-
|
|
1503
|
-
{codeVerifier: BetterAuth::Crypto.random_string(128)}
|
|
1504
|
-
end
|
|
1505
|
-
|
|
1506
|
-
def sso_decode_state(state, secret)
|
|
1507
|
-
BetterAuth::Crypto.verify_jwt(state.to_s, secret)
|
|
1508
|
-
rescue
|
|
1509
|
-
nil
|
|
1510
|
-
end
|
|
1511
|
-
|
|
1512
|
-
def sso_base64_urlsafe(value)
|
|
1513
|
-
Base64.strict_encode64(value).tr("+/", "-_").delete("=")
|
|
1514
|
-
end
|
|
1515
|
-
|
|
1516
|
-
def sso_storage_config(config)
|
|
1517
|
-
normalize_hash(config || {}).each_with_object({}) do |(key, value), result|
|
|
1518
|
-
result[Schema.storage_key(key)] = value unless value.respond_to?(:call)
|
|
1519
|
-
end
|
|
1520
|
-
end
|
|
1521
|
-
|
|
1522
|
-
def sso_provider_limit(user, config)
|
|
1523
|
-
limit = config[:providers_limit]
|
|
1524
|
-
limit = 10 if limit.nil?
|
|
1525
|
-
limit.respond_to?(:call) ? limit.call(user) : limit
|
|
1526
|
-
end
|
|
1527
|
-
|
|
1528
|
-
def sso_validate_url!(value, message)
|
|
1529
|
-
uri = URI(value.to_s)
|
|
1530
|
-
unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
1531
|
-
raise APIError.new("BAD_REQUEST", message: message)
|
|
1532
|
-
end
|
|
1533
|
-
rescue URI::InvalidURIError
|
|
1534
|
-
raise APIError.new("BAD_REQUEST", message: message)
|
|
1535
|
-
end
|
|
1536
|
-
|
|
1537
|
-
def sso_validate_organization_membership!(ctx, user_id, organization_id)
|
|
1538
|
-
member = ctx.context.adapter.find_one(
|
|
1539
|
-
model: "member",
|
|
1540
|
-
where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
|
|
1541
|
-
)
|
|
1542
|
-
raise APIError.new("BAD_REQUEST", message: "You are not a member of the organization") unless member
|
|
1543
|
-
end
|
|
1544
|
-
|
|
1545
|
-
def sso_hydrate_oidc_config(issuer, oidc_config, ctx)
|
|
1546
|
-
existing = oidc_config.merge(issuer: issuer)
|
|
1547
|
-
discovered = sso_discover_oidc_config(
|
|
1548
|
-
issuer: issuer,
|
|
1549
|
-
existing_config: existing,
|
|
1550
|
-
fetch: ctx.context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:oidc_discovery_fetch, nil),
|
|
1551
|
-
trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) }
|
|
1552
|
-
)
|
|
1553
|
-
existing.merge(discovered)
|
|
1554
|
-
end
|
|
1555
|
-
|
|
1556
|
-
def sso_oidc_needs_runtime_discovery?(oidc_config)
|
|
1557
|
-
config = normalize_hash(oidc_config || {})
|
|
1558
|
-
config[:authorization_endpoint].to_s.empty? ||
|
|
1559
|
-
config[:token_endpoint].to_s.empty?
|
|
1560
|
-
end
|
|
1561
|
-
|
|
1562
|
-
def sso_ensure_runtime_oidc_provider(ctx, provider, plugin_config, require_jwks: false)
|
|
1563
|
-
oidc_config = normalize_hash(provider["oidcConfig"] || {})
|
|
1564
|
-
needs_discovery = sso_oidc_needs_runtime_discovery?(oidc_config) || (require_jwks && oidc_config[:jwks_endpoint].to_s.empty?)
|
|
1565
|
-
return provider if !needs_discovery
|
|
1566
|
-
|
|
1567
|
-
discovered = sso_discover_oidc_config(
|
|
1568
|
-
issuer: provider.fetch("issuer"),
|
|
1569
|
-
existing_config: oidc_config.merge(issuer: provider.fetch("issuer")),
|
|
1570
|
-
fetch: plugin_config[:oidc_discovery_fetch],
|
|
1571
|
-
trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) }
|
|
1572
|
-
)
|
|
1573
|
-
provider.merge("oidcConfig" => oidc_config.merge(discovered))
|
|
1574
|
-
end
|
|
1575
|
-
|
|
1576
|
-
def sso_oidc_redirect_uri(context, provider_id)
|
|
1577
|
-
redirect_uri = context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:redirect_uri, nil)
|
|
1578
|
-
if redirect_uri && !redirect_uri.to_s.strip.empty?
|
|
1579
|
-
value = redirect_uri.to_s
|
|
1580
|
-
return value if URI(value).absolute?
|
|
1581
|
-
|
|
1582
|
-
path = value.start_with?("/") ? value : "/#{value}"
|
|
1583
|
-
return "#{context.base_url}#{path}"
|
|
1584
|
-
end
|
|
1585
|
-
|
|
1586
|
-
"#{context.base_url}/sso/callback/#{provider_id}"
|
|
1587
|
-
rescue URI::InvalidURIError
|
|
1588
|
-
"#{context.base_url}/sso/callback/#{provider_id}"
|
|
1589
|
-
end
|
|
1590
|
-
|
|
1591
|
-
def sso_email_domain_matches?(email_domain, provider_domain)
|
|
1592
|
-
email_domain = email_domain.to_s.strip.downcase
|
|
1593
|
-
email_domain = email_domain.split("@", 2).last if email_domain.include?("@")
|
|
1594
|
-
return false if email_domain.to_s.empty?
|
|
1595
|
-
|
|
1596
|
-
provider_domain.to_s.split(",").map { |value| value.strip.downcase }.reject(&:empty?).any? do |domain|
|
|
1597
|
-
email_domain == domain || email_domain.end_with?(".#{domain}")
|
|
1598
|
-
end
|
|
1599
|
-
end
|
|
1600
|
-
|
|
1601
|
-
def sso_find_provider!(ctx, provider_id)
|
|
1602
|
-
provider = ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id.to_s}])
|
|
1603
|
-
raise APIError.new("NOT_FOUND", message: "Provider not found", code: "PROVIDER_NOT_FOUND") unless provider
|
|
1604
|
-
|
|
1605
|
-
provider
|
|
1606
|
-
end
|
|
1607
|
-
|
|
1608
|
-
def sso_provider_access?(provider, user_id, ctx)
|
|
1609
|
-
organization_id = provider["organizationId"]
|
|
1610
|
-
return provider["userId"] == user_id if organization_id.to_s.empty?
|
|
1611
|
-
return provider["userId"] == user_id unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
|
|
1612
|
-
|
|
1613
|
-
member = ctx.context.adapter.find_one(
|
|
1614
|
-
model: "member",
|
|
1615
|
-
where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
|
|
1616
|
-
)
|
|
1617
|
-
Array(member&.fetch("role", nil).to_s.split(",")).map(&:strip).any? { |role| %w[owner admin].include?(role) }
|
|
1618
|
-
end
|
|
1619
|
-
|
|
1620
|
-
def sso_authorize_domain_verification!(ctx, provider, user_id)
|
|
1621
|
-
organization_id = provider["organizationId"]
|
|
1622
|
-
is_org_member = true
|
|
1623
|
-
if organization_id
|
|
1624
|
-
is_org_member = !!ctx.context.adapter.find_one(
|
|
1625
|
-
model: "member",
|
|
1626
|
-
where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
|
|
1627
|
-
)
|
|
1628
|
-
end
|
|
1629
|
-
return if provider["userId"] == user_id && is_org_member
|
|
1630
|
-
|
|
1631
|
-
raise APIError.new("FORBIDDEN", message: "User must be owner of or belong to the SSO provider organization", code: "INSUFICCIENT_ACCESS")
|
|
1632
|
-
end
|
|
1633
|
-
|
|
1634
|
-
def sso_domain_verification_identifier(config, provider_id)
|
|
1635
|
-
prefix = config.dig(:domain_verification, :token_prefix) || "better-auth-token"
|
|
1636
|
-
"_#{prefix}-#{provider_id}"
|
|
1637
|
-
end
|
|
1638
|
-
|
|
1639
|
-
def sso_future_time?(value)
|
|
1640
|
-
time = value.is_a?(Time) ? value : Time.parse(value.to_s)
|
|
1641
|
-
time > Time.now
|
|
1642
|
-
rescue
|
|
1643
|
-
false
|
|
1644
|
-
end
|
|
1645
|
-
|
|
1646
|
-
def sso_hostname_from_domain(domain)
|
|
1647
|
-
value = domain.to_s.strip
|
|
1648
|
-
return nil if value.empty?
|
|
1649
|
-
|
|
1650
|
-
uri = URI(value.include?("://") ? value : "https://#{value}")
|
|
1651
|
-
uri.host
|
|
1652
|
-
rescue URI::InvalidURIError
|
|
1653
|
-
nil
|
|
1654
|
-
end
|
|
1655
|
-
|
|
1656
|
-
def sso_resolve_txt_records(hostname, config)
|
|
1657
|
-
resolver = config.dig(:domain_verification, :dns_txt_resolver)
|
|
1658
|
-
return Array(resolver.call(hostname)) if resolver.respond_to?(:call)
|
|
1659
|
-
|
|
1660
|
-
Resolv::DNS.open do |dns|
|
|
1661
|
-
dns.getresources(hostname, Resolv::DNS::Resource::IN::TXT).map { |record| record.strings }
|
|
1662
|
-
end
|
|
1663
|
-
rescue
|
|
1664
|
-
[]
|
|
1665
|
-
end
|
|
1666
|
-
|
|
1667
|
-
def sso_sanitize_provider(provider, context)
|
|
1668
|
-
data = provider.dup
|
|
1669
|
-
oidc_config = sso_provider_config_hash(data["oidcConfig"])
|
|
1670
|
-
saml_config = sso_provider_config_hash(data["samlConfig"])
|
|
1671
|
-
data["type"] = saml_config.empty? ? "oidc" : "saml"
|
|
1672
|
-
data["organizationId"] ||= nil
|
|
1673
|
-
data["domainVerified"] = !!data["domainVerified"]
|
|
1674
|
-
data.delete("domainVerified") unless sso_context_domain_verification_enabled?(context)
|
|
1675
|
-
data["oidcConfig"] = oidc_config.empty? ? nil : sso_sanitize_oidc_config(oidc_config)
|
|
1676
|
-
data["samlConfig"] = saml_config.empty? ? nil : sso_sanitize_saml_config(saml_config)
|
|
1677
|
-
data["spMetadataUrl"] = "#{context.base_url}/sso/saml2/sp/metadata?providerId=#{URI.encode_www_form_component(data.fetch("providerId"))}"
|
|
1678
|
-
data.compact
|
|
1679
|
-
end
|
|
1680
|
-
|
|
1681
|
-
def sso_provider_config_hash(value)
|
|
1682
|
-
return normalize_hash(value) if value.is_a?(Hash)
|
|
1683
|
-
return {} if value.nil? || value.to_s.strip.empty?
|
|
1684
|
-
|
|
1685
|
-
parsed = JSON.parse(value.to_s)
|
|
1686
|
-
normalize_hash(parsed)
|
|
1687
|
-
rescue JSON::ParserError, TypeError
|
|
1688
|
-
{}
|
|
1689
|
-
end
|
|
1690
|
-
|
|
1691
|
-
def sso_context_domain_verification_enabled?(context)
|
|
1692
|
-
context.options.plugins.any? do |plugin|
|
|
1693
|
-
plugin.id == "sso" && plugin.options.dig(:domain_verification, :enabled)
|
|
1694
|
-
end
|
|
1695
|
-
end
|
|
1696
|
-
|
|
1697
|
-
def sso_sanitize_config(config)
|
|
1698
|
-
data = normalize_hash(config || {})
|
|
1699
|
-
data.delete(:client_secret)
|
|
1700
|
-
data.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value unless value.respond_to?(:call) }
|
|
1701
|
-
end
|
|
1702
|
-
|
|
1703
|
-
def sso_sanitize_oidc_config(config)
|
|
1704
|
-
{
|
|
1705
|
-
"clientIdLastFour" => sso_mask_client_id(config[:client_id]),
|
|
1706
|
-
"authorizationEndpoint" => config[:authorization_endpoint],
|
|
1707
|
-
"tokenEndpoint" => config[:token_endpoint],
|
|
1708
|
-
"userInfoEndpoint" => config[:user_info_endpoint],
|
|
1709
|
-
"jwksEndpoint" => config[:jwks_endpoint],
|
|
1710
|
-
"scopes" => config[:scopes],
|
|
1711
|
-
"tokenEndpointAuthentication" => config[:token_endpoint_authentication],
|
|
1712
|
-
"pkce" => config[:pkce],
|
|
1713
|
-
"discoveryEndpoint" => config[:discovery_endpoint],
|
|
1714
|
-
"mapping" => config[:mapping] && sso_sanitize_config(config[:mapping])
|
|
1715
|
-
}.compact
|
|
1716
|
-
end
|
|
1717
|
-
|
|
1718
|
-
def sso_sanitize_saml_config(config)
|
|
1719
|
-
{
|
|
1720
|
-
"entryPoint" => config[:entry_point],
|
|
1721
|
-
"callbackUrl" => config[:callback_url],
|
|
1722
|
-
"audience" => config[:audience],
|
|
1723
|
-
"wantAssertionsSigned" => config[:want_assertions_signed],
|
|
1724
|
-
"authnRequestsSigned" => config[:authn_requests_signed],
|
|
1725
|
-
"identifierFormat" => config[:identifier_format],
|
|
1726
|
-
"signatureAlgorithm" => config[:signature_algorithm],
|
|
1727
|
-
"digestAlgorithm" => config[:digest_algorithm],
|
|
1728
|
-
"certificate" => sso_parse_certificate(config[:cert]),
|
|
1729
|
-
"idpMetadata" => sso_sanitize_saml_metadata_config(config[:idp_metadata]),
|
|
1730
|
-
"spMetadata" => sso_sanitize_saml_metadata_config(config[:sp_metadata]),
|
|
1731
|
-
"mapping" => config[:mapping] && sso_sanitize_config(config[:mapping])
|
|
1732
|
-
}.compact
|
|
1733
|
-
end
|
|
1734
|
-
|
|
1735
|
-
def sso_sanitize_saml_metadata_config(metadata)
|
|
1736
|
-
data = normalize_hash(metadata || {})
|
|
1737
|
-
return nil if data.empty?
|
|
1738
|
-
|
|
1739
|
-
data.except(:private_key, :private_key_pass, :enc_private_key, :enc_private_key_pass, :decryption_pvk).each_with_object({}) do |(key, value), result|
|
|
1740
|
-
result[(key == :entity_id) ? "entityID" : Schema.storage_key(key)] = value
|
|
1741
|
-
end
|
|
1742
|
-
end
|
|
1743
|
-
|
|
1744
|
-
def sso_mask_client_id(client_id)
|
|
1745
|
-
value = client_id.to_s
|
|
1746
|
-
return "****" if value.length <= 4
|
|
1747
|
-
|
|
1748
|
-
"****#{value[-4, 4]}"
|
|
1749
|
-
end
|
|
1750
|
-
|
|
1751
|
-
def sso_parse_certificate(cert)
|
|
1752
|
-
OpenSSL::X509::Certificate.new(cert.to_s)
|
|
1753
|
-
{subject: cert.to_s.lines.first.to_s.strip}
|
|
1754
|
-
rescue
|
|
1755
|
-
{error: "Failed to parse certificate"}
|
|
1756
|
-
end
|
|
1757
|
-
|
|
1758
|
-
def sso_fetch(data, key)
|
|
1759
|
-
return nil unless data.respond_to?(:[])
|
|
1760
|
-
|
|
1761
|
-
compact = key.to_s.delete("_").downcase
|
|
1762
|
-
direct = data[key] ||
|
|
1763
|
-
data[key.to_s] ||
|
|
1764
|
-
data[Schema.storage_key(key)] ||
|
|
1765
|
-
data[Schema.storage_key(key).to_sym] ||
|
|
1766
|
-
data[compact] ||
|
|
1767
|
-
data[compact.to_sym]
|
|
1768
|
-
return direct unless direct.nil?
|
|
1769
|
-
|
|
1770
|
-
data.each do |candidate, value|
|
|
1771
|
-
normalized = candidate.to_s.delete("_").downcase
|
|
1772
|
-
return value if normalized == compact
|
|
1773
|
-
end
|
|
1774
|
-
nil
|
|
1775
|
-
end
|
|
1776
|
-
|
|
1777
|
-
def sso_redirect(ctx, location)
|
|
1778
|
-
[302, ctx.response_headers.merge("location" => location), [""]]
|
|
1779
|
-
end
|
|
1780
|
-
end
|
|
1781
|
-
end
|
|
16
|
+
require_relative "../sso/plugin/core"
|
|
17
|
+
require_relative "../sso/plugin/oidc_discovery"
|
|
18
|
+
require_relative "../sso/plugin/providers"
|
|
19
|
+
require_relative "../sso/plugin/sign_in_and_oidc_callbacks"
|
|
20
|
+
require_relative "../sso/plugin/endpoints"
|
|
21
|
+
require_relative "../sso/plugin/saml_response"
|
|
22
|
+
require_relative "../sso/plugin/saml_metadata_and_logout"
|
|
23
|
+
require_relative "../sso/plugin/saml_validation_and_state"
|
|
24
|
+
require_relative "../sso/plugin/oidc_runtime"
|
|
25
|
+
require_relative "../sso/plugin/provider_utils"
|