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