better_auth-sso 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bcacfff0297f46fdb059e52b7e19de70697c39cfbcb188e0c36a10631b6208d5
4
- data.tar.gz: f3a150f3b5a9f547bdf4edabf15d4f48321b5e50a20283c82011fd67bc154ece
3
+ metadata.gz: ebd55984df929b46f41984bb799d40ae89018e02189619a60485316d6c1943b4
4
+ data.tar.gz: e5f7aa9680058c33c2413f08385fcf73ab3c10ece70c06e999dda40a24676366
5
5
  SHA512:
6
- metadata.gz: 603b6c5b7a6141457f5ba8046a44bad630ec37ef2232707acc8d9dcfc92b4fd0a88c48e5db7e4c6fd9e4c73a6d60e58b8857a2f39e6ceae126a77d0d5af6bf72
7
- data.tar.gz: d2aff34cd79bb9b9d072933f5f33fc30ee31fe7a0ac5f651c433916c9001dbf0fee9539e6714bbab5bf24af9d5b1ff153929bb47346d6700b0675c4428825324
6
+ metadata.gz: 6cad2fc9ab83b28280f93f2d2a521ec9dcc12854f0000f5797674fdcdae285f28d7435bc6dbaee92bb9e1f47bf6573ba3e527bf87e3e5a221cf5578eab1f245a
7
+ data.tar.gz: 7f4b8739cac30c78ba7eaffd7e097037d6c062a92ef777f98eee039072f9efa2b098b3b2f35d81c8bda04646ce220d9566c0e5709988bb59c823980ac5d7f435
@@ -6,6 +6,7 @@ require "json"
6
6
  require "jwt"
7
7
  require "net/http"
8
8
  require "openssl"
9
+ require "rexml/document"
9
10
  require "resolv"
10
11
  require "securerandom"
11
12
  require "time"
@@ -67,6 +68,7 @@ module BetterAuth
67
68
  ].freeze
68
69
  SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024
69
70
  SSO_DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024
71
+ SSO_SAML_RELAY_STATE_KEY_PREFIX = "saml-relay-state:"
70
72
  SSO_SAML_AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:"
71
73
  SSO_DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000
72
74
  SSO_SAML_USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:"
@@ -83,29 +85,11 @@ module BetterAuth
83
85
  if defined?(BetterAuth::SSO::SAML) && defined?(BetterAuth::SSO::SAMLHooks)
84
86
  config = BetterAuth::SSO::SAMLHooks.merge_options(BetterAuth::SSO::SAML.sso_options, config)
85
87
  end
86
- endpoints = {
87
- sp_metadata: sso_sp_metadata_endpoint(config),
88
- register_sso_provider: sso_register_provider_endpoint(config),
89
- sign_in_sso: sso_sign_in_endpoint(config),
90
- callback_sso: sso_oidc_callback_endpoint(config),
91
- callback_sso_shared: sso_oidc_shared_callback_endpoint(config),
92
- callback_sso_saml: sso_saml_callback_endpoint(config),
93
- acs_endpoint: sso_saml_acs_endpoint(config),
94
- slo_endpoint: sso_saml_slo_endpoint(config),
95
- initiate_slo: sso_initiate_slo_endpoint(config),
96
- list_sso_providers: sso_list_providers_endpoint,
97
- get_sso_provider: sso_get_provider_endpoint,
98
- update_sso_provider: sso_update_provider_endpoint,
99
- delete_sso_provider: sso_delete_provider_endpoint
100
- }
101
- if config.dig(:domain_verification, :enabled)
102
- endpoints[:request_domain_verification] = sso_request_domain_verification_endpoint(config)
103
- endpoints[:verify_domain] = sso_verify_domain_endpoint(config)
104
- end
88
+ endpoints = BetterAuth::SSO::Routes::SSO.endpoints(config)
105
89
  Plugin.new(
106
90
  id: "sso",
107
91
  init: ->(_ctx) { {options: {advanced: {disable_origin_check: ["/sso/saml2/callback", "/sso/saml2/sp/acs", "/sso/saml2/sp/slo"]}}} },
108
- schema: sso_schema(config),
92
+ schema: BetterAuth::SSO::Routes::Schemas.plugin_schema(config),
109
93
  endpoints: endpoints,
110
94
  error_codes: SSO_ERROR_CODES,
111
95
  options: config
@@ -113,24 +97,7 @@ module BetterAuth
113
97
  end
114
98
 
115
99
  def sso_schema(config = {})
116
- fields = {
117
- issuer: {type: "string", required: true},
118
- oidcConfig: {type: "string", required: false},
119
- samlConfig: {type: "string", required: false},
120
- userId: {type: "string", required: true},
121
- providerId: {type: "string", required: true, unique: true},
122
- domain: {type: "string", required: true},
123
- organizationId: {type: "string", required: false}
124
- }
125
- if config.dig(:domain_verification, :enabled)
126
- fields[:domainVerified] = {type: "boolean", required: false, default_value: false}
127
- end
128
- {
129
- ssoProvider: {
130
- model_name: config[:model_name] || "ssoProviders",
131
- fields: fields
132
- }
133
- }
100
+ BetterAuth::SSO::Routes::Schemas.plugin_schema(config)
134
101
  end
135
102
 
136
103
  def sso_discover_oidc_config(issuer:, fetch: nil, existing_config: nil, discovery_endpoint: nil, trusted_origin: nil, timeout: nil)
@@ -242,8 +209,18 @@ module BetterAuth
242
209
  domainVerified: false
243
210
  }
244
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
245
221
  response = sso_sanitize_provider(provider, ctx.context)
246
222
  response[:redirectURI] = sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId"))
223
+ response[:domainVerificationToken] = domain_verification_token if domain_verification_token
247
224
  ctx.json(response)
248
225
  end
249
226
  end
@@ -284,13 +261,13 @@ module BetterAuth
284
261
  update[:domain] = body[:domain].to_s.downcase if body.key?(:domain)
285
262
  update[:domainVerified] = false if body.key?(:domain) && body[:domain].to_s.downcase != provider["domain"].to_s
286
263
  if body.key?(:oidc_config)
287
- current = normalize_hash(provider["oidcConfig"] || {})
264
+ current = sso_provider_config_hash(provider["oidcConfig"])
288
265
  raise APIError.new("BAD_REQUEST", message: "Cannot update OIDC config for a provider that doesn't have OIDC configured") if current.empty?
289
266
 
290
267
  update[:oidcConfig] = current.merge(normalize_hash(body[:oidc_config]))
291
268
  end
292
269
  if body.key?(:saml_config)
293
- current = normalize_hash(provider["samlConfig"] || {})
270
+ current = sso_provider_config_hash(provider["samlConfig"])
294
271
  raise APIError.new("BAD_REQUEST", message: "Cannot update SAML config for a provider that doesn't have SAML configured") if current.empty?
295
272
 
296
273
  update[:samlConfig] = current.merge(normalize_hash(body[:saml_config]))
@@ -339,7 +316,7 @@ module BetterAuth
339
316
  state = BetterAuth::Crypto.sign_jwt(state_data.merge(sso_oidc_pkce_state(provider)), ctx.context.secret, expires_in: 600)
340
317
  url = sso_oidc_authorization_url(provider, ctx, state, config, body)
341
318
  elsif provider["samlConfig"]
342
- relay_state = BetterAuth::Crypto.sign_jwt(state_data.merge(nonce: SecureRandom.hex(8)), ctx.context.secret, expires_in: 600)
319
+ relay_state = sso_generate_saml_relay_state(ctx, state_data)
343
320
  url = sso_saml_authorization_url(provider, relay_state, ctx, config)
344
321
  sso_store_saml_authn_request(ctx, provider, url, config)
345
322
  else
@@ -459,12 +436,14 @@ module BetterAuth
459
436
  end
460
437
 
461
438
  sso_process_saml_logout_request(ctx, provider, sso_fetch(ctx.body, :saml_request) || sso_fetch(ctx.query, :saml_request))
462
- response = Base64.strict_encode64("<samlp:LogoutResponse xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" ID=\"_#{SecureRandom.hex(16)}\" 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>")
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>")
463
440
  if sso_fetch(ctx.body, :saml_request)
464
441
  next sso_saml_post_form(sso_saml_logout_destination(provider), "SAMLResponse", response, relay_state)
465
442
  end
466
443
 
467
- sso_redirect(ctx, "#{sso_saml_logout_destination(provider)}?#{URI.encode_www_form(SAMLResponse: response, RelayState: relay_state)}")
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)}")
468
447
  end
469
448
  end
470
449
 
@@ -488,7 +467,7 @@ module BetterAuth
488
467
  name_id = saml_record["nameId"] || user_email
489
468
  session_index = saml_record["sessionIndex"]
490
469
 
491
- request_id = "_#{SecureRandom.hex(16)}"
470
+ request_id = "_#{BetterAuth::Crypto.random_string(32)}"
492
471
  session_index_xml = session_index.to_s.empty? ? "" : "<samlp:SessionIndex>#{CGI.escapeHTML(session_index.to_s)}</samlp:SessionIndex>"
493
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>")
494
473
  sso_store_saml_logout_request(ctx, provider, request_id, config)
@@ -496,7 +475,9 @@ module BetterAuth
496
475
  ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}")
497
476
  ctx.context.internal_adapter.delete_session(session_token)
498
477
  Cookies.delete_session_cookie(ctx)
499
- sso_redirect(ctx, "#{destination}?#{URI.encode_www_form(SAMLRequest: request, RelayState: relay_state)}")
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)}")
500
481
  end
501
482
  end
502
483
 
@@ -515,7 +496,7 @@ module BetterAuth
515
496
  next ctx.json({domainVerificationToken: active.fetch("value")}, status: 201)
516
497
  end
517
498
 
518
- token = SecureRandom.alphanumeric(24)
499
+ token = BetterAuth::Crypto.random_string(24)
519
500
  ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: token, expiresAt: Time.now + (7 * 24 * 60 * 60))
520
501
  config.dig(:domain_verification, :request)&.call(provider: provider, token: token, context: ctx)
521
502
  ctx.json({domainVerificationToken: token}, status: 201)
@@ -559,8 +540,16 @@ module BetterAuth
559
540
  def sso_handle_saml_response(ctx, config = {})
560
541
  provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
561
542
  relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
562
- state = sso_verify_state(relay_state, ctx.context.secret) || {}
563
- raw_response = sso_fetch(ctx.body, :saml_response)
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
564
553
  max_response_size = config.dig(:saml, :max_response_size) || SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE
565
554
  if raw_response.to_s.bytesize > max_response_size
566
555
  raise APIError.new("BAD_REQUEST", message: "SAML response exceeds maximum allowed size (#{max_response_size} bytes)")
@@ -579,14 +568,15 @@ module BetterAuth
579
568
  end
580
569
  ctx.context.internal_adapter.create_verification_value(identifier: replay_key, value: "used", expiresAt: sso_saml_assertion_replay_expires_at(assertion, config))
581
570
 
582
- callback_url = state["callbackURL"] || "/"
583
- callback_url = "/" unless ctx.context.trusted_origin?(callback_url, allow_relative_paths: true)
571
+ callback_url = sso_safe_saml_callback_url(ctx, state["callbackURL"] || sso_saml_callback_url(provider) || "/", provider.fetch("providerId"))
584
572
  email = (assertion[:email] || assertion["email"]).to_s.downcase
585
573
  if config[:disable_implicit_sign_up] && !state["requestSignUp"] && !ctx.context.internal_adapter.find_user_by_email(email)
586
574
  return sso_redirect(ctx, sso_append_error(callback_url, "signup disabled"))
587
575
  end
588
576
 
589
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
+
590
580
  user = result.fetch(:user)
591
581
  if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
592
582
  config[:provision_user].call(user: user, userInfo: assertion, provider: provider)
@@ -604,9 +594,32 @@ module BetterAuth
604
594
  def sso_find_or_create_user_result(ctx, provider, user_info, config = {})
605
595
  user_info = normalize_hash(user_info)
606
596
  email = user_info[:email].to_s.downcase
607
- found = ctx.context.internal_adapter.find_user_by_email(email)
608
- if found
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
+
609
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
610
623
  oidc_config = normalize_hash(provider["oidcConfig"] || {})
611
624
  if oidc_config[:override_user_info] || config[:default_override_user_info]
612
625
  update = {}
@@ -624,8 +637,8 @@ module BetterAuth
624
637
  image: user_info[:image]
625
638
  )
626
639
  ctx.context.internal_adapter.create_account(
627
- accountId: (user_info[:id] || created.fetch("id")).to_s,
628
- providerId: "sso:#{provider.fetch("providerId")}",
640
+ accountId: account_id.empty? ? created.fetch("id") : account_id,
641
+ providerId: storage_provider_id,
629
642
  userId: created.fetch("id")
630
643
  )
631
644
  user = created
@@ -635,6 +648,15 @@ module BetterAuth
635
648
  {user: user, created: created}
636
649
  end
637
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
+
638
660
  def sso_validate_saml_response!(config, assertion, provider, ctx)
639
661
  validator = config.dig(:saml, :validate_response)
640
662
  return unless validator.respond_to?(:call)
@@ -653,6 +675,10 @@ module BetterAuth
653
675
  if saml_config[:entry_point].to_s.empty? && saml_config[:single_sign_on_service].to_s.empty? && metadata.to_s.empty?
654
676
  raise APIError.new("BAD_REQUEST", message: "SAML config must include entryPoint, singleSignOnService, or IdP metadata")
655
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
656
682
 
657
683
  sso_validate_saml_algorithms!(
658
684
  metadata.to_s,
@@ -667,16 +693,112 @@ module BetterAuth
667
693
  def sso_sp_metadata_xml(ctx, provider, config = {})
668
694
  provider_id = provider.fetch("providerId")
669
695
  saml_config = normalize_hash(provider["samlConfig"] || {})
670
- entity_id = saml_config.dig(:sp_metadata, :entity_id) || saml_config[:audience] || "#{ctx.context.base_url}/sso/saml2/sp/metadata?providerId=#{URI.encode_www_form_component(provider_id)}"
671
- acs_url = saml_config[:callback_url] || "#{ctx.context.base_url}/sso/saml2/sp/acs/#{URI.encode_www_form_component(provider_id)}"
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)
672
701
  authn_requests_signed = !!saml_config[:authn_requests_signed]
673
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>"
674
704
  slo = if config.dig(:saml, :enable_single_logout)
675
705
  location = "#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}"
676
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}\" />"
677
707
  end
678
708
 
679
- "<EntityDescriptor entityID=\"#{entity_id}\"><SPSSODescriptor AuthnRequestsSigned=\"#{authn_requests_signed}\" WantAssertionsSigned=\"#{want_assertions_signed}\">#{slo}<AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Location=\"#{acs_url}\" index=\"0\" /></SPSSODescriptor></EntityDescriptor>"
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]
680
802
  end
681
803
 
682
804
  def sso_saml_logout_destination(provider)
@@ -687,14 +809,8 @@ module BetterAuth
687
809
  saml_config[:logout_url]
688
810
  return direct unless direct.to_s.empty?
689
811
 
690
- idp_metadata = normalize_hash(saml_config[:idp_metadata] || {})
691
- structured = idp_metadata[:single_logout_service] || saml_config[:single_logout_service]
692
- structured = structured.first if structured.is_a?(Array)
693
- structured = normalize_hash(structured) if structured.is_a?(Hash)
694
- return structured[:location] if structured.is_a?(Hash) && !structured[:location].to_s.empty?
695
-
696
- metadata = idp_metadata[:metadata] || saml_config[:metadata] || saml_config[:idp_metadata_xml]
697
- metadata.to_s[/<[^>]*SingleLogoutService\b[^>]*\bLocation=['"]([^'"]+)['"]/, 1]
812
+ service = sso_saml_preferred_service(sso_saml_idp_metadata(saml_config)[:single_logout_service])
813
+ normalize_hash(service || {})[:location]
698
814
  end
699
815
 
700
816
  def sso_store_saml_session(ctx, provider, assertion, session)
@@ -806,6 +922,55 @@ module BetterAuth
806
922
  app_origin
807
923
  end
808
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
+
809
974
  def sso_saml_post_form(action, saml_param, saml_value, relay_state = nil)
810
975
  relay_input = relay_state.to_s.empty? ? "" : "<input type=\"hidden\" name=\"RelayState\" value=\"#{CGI.escapeHTML(relay_state.to_s)}\" />"
811
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>"
@@ -970,6 +1135,39 @@ module BetterAuth
970
1135
  end
971
1136
  end
972
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
+
973
1171
  def sso_verify_state(value, secret)
974
1172
  BetterAuth::Crypto.verify_jwt(value.to_s, secret)
975
1173
  rescue
@@ -1006,11 +1204,13 @@ module BetterAuth
1006
1204
  end
1007
1205
 
1008
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]
1009
1209
  query = {
1010
1210
  SAMLRequest: Base64.strict_encode64(JSON.generate({providerId: provider.fetch("providerId")})),
1011
1211
  RelayState: relay_state
1012
1212
  }
1013
- "#{config[:entry_point]}?#{URI.encode_www_form(query)}"
1213
+ "#{entry_point}?#{URI.encode_www_form(query)}"
1014
1214
  end
1015
1215
 
1016
1216
  def sso_store_saml_authn_request(ctx, provider, url, config)
@@ -1300,7 +1500,7 @@ module BetterAuth
1300
1500
  def sso_oidc_pkce_state(provider)
1301
1501
  return {} unless normalize_hash(provider["oidcConfig"] || {})[:pkce]
1302
1502
 
1303
- {codeVerifier: SecureRandom.urlsafe_base64(48)}
1503
+ {codeVerifier: BetterAuth::Crypto.random_string(128)}
1304
1504
  end
1305
1505
 
1306
1506
  def sso_decode_state(state, secret)
@@ -1400,7 +1600,7 @@ module BetterAuth
1400
1600
 
1401
1601
  def sso_find_provider!(ctx, provider_id)
1402
1602
  provider = ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id.to_s}])
1403
- raise APIError.new("NOT_FOUND", message: SSO_ERROR_CODES.fetch("PROVIDER_NOT_FOUND")) unless provider
1603
+ raise APIError.new("NOT_FOUND", message: "Provider not found", code: "PROVIDER_NOT_FOUND") unless provider
1404
1604
 
1405
1605
  provider
1406
1606
  end
@@ -1466,8 +1666,8 @@ module BetterAuth
1466
1666
 
1467
1667
  def sso_sanitize_provider(provider, context)
1468
1668
  data = provider.dup
1469
- oidc_config = normalize_hash(data["oidcConfig"] || {})
1470
- saml_config = normalize_hash(data["samlConfig"] || {})
1669
+ oidc_config = sso_provider_config_hash(data["oidcConfig"])
1670
+ saml_config = sso_provider_config_hash(data["samlConfig"])
1471
1671
  data["type"] = saml_config.empty? ? "oidc" : "saml"
1472
1672
  data["organizationId"] ||= nil
1473
1673
  data["domainVerified"] = !!data["domainVerified"]
@@ -1478,6 +1678,16 @@ module BetterAuth
1478
1678
  data.compact
1479
1679
  end
1480
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
+
1481
1691
  def sso_context_domain_verification_enabled?(context)
1482
1692
  context.options.plugins.any? do |plugin|
1483
1693
  plugin.id == "sso" && plugin.options.dig(:domain_verification, :enabled)
@@ -1500,7 +1710,8 @@ module BetterAuth
1500
1710
  "scopes" => config[:scopes],
1501
1711
  "tokenEndpointAuthentication" => config[:token_endpoint_authentication],
1502
1712
  "pkce" => config[:pkce],
1503
- "discoveryEndpoint" => config[:discovery_endpoint]
1713
+ "discoveryEndpoint" => config[:discovery_endpoint],
1714
+ "mapping" => config[:mapping] && sso_sanitize_config(config[:mapping])
1504
1715
  }.compact
1505
1716
  end
1506
1717
 
@@ -1514,10 +1725,22 @@ module BetterAuth
1514
1725
  "identifierFormat" => config[:identifier_format],
1515
1726
  "signatureAlgorithm" => config[:signature_algorithm],
1516
1727
  "digestAlgorithm" => config[:digest_algorithm],
1517
- "certificate" => sso_parse_certificate(config[:cert])
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])
1518
1732
  }.compact
1519
1733
  end
1520
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
+
1521
1744
  def sso_mask_client_id(client_id)
1522
1745
  value = client_id.to_s
1523
1746
  return "****" if value.length <= 4
@@ -1533,13 +1756,22 @@ module BetterAuth
1533
1756
  end
1534
1757
 
1535
1758
  def sso_fetch(data, key)
1759
+ return nil unless data.respond_to?(:[])
1760
+
1536
1761
  compact = key.to_s.delete("_").downcase
1537
- data[key] ||
1762
+ direct = data[key] ||
1538
1763
  data[key.to_s] ||
1539
1764
  data[Schema.storage_key(key)] ||
1540
1765
  data[Schema.storage_key(key).to_sym] ||
1541
1766
  data[compact] ||
1542
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
1543
1775
  end
1544
1776
 
1545
1777
  def sso_redirect(ctx, location)
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ module Client
6
+ ID = "sso-client"
7
+ PATH_METHODS = {
8
+ "/sso/providers" => "GET",
9
+ "/sso/get-provider" => "GET"
10
+ }.freeze
11
+
12
+ module_function
13
+
14
+ def sso_client(options = {})
15
+ domain_verification = options[:domain_verification] || options["domainVerification"] || options["domain_verification"] || {}
16
+ enabled = domain_verification[:enabled] || domain_verification["enabled"] || false
17
+
18
+ {
19
+ id: ID,
20
+ version: VERSION,
21
+ infer_server_plugin: {
22
+ domain_verification: {
23
+ enabled: !!enabled
24
+ }
25
+ },
26
+ path_methods: PATH_METHODS
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end