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.
@@ -13,1769 +13,13 @@ require "time"
13
13
  require "uri"
14
14
  require "zlib"
15
15
 
16
- module BetterAuth
17
- module Plugins
18
- module_function
19
-
20
- remove_method :sso if method_defined?(:sso) || private_method_defined?(:sso)
21
- singleton_class.remove_method(:sso) if singleton_class.method_defined?(:sso) || singleton_class.private_method_defined?(:sso)
22
-
23
- SSO_ERROR_CODES = {
24
- "PROVIDER_NOT_FOUND" => "No provider found",
25
- "INVALID_STATE" => "Invalid state",
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"