Module: BetterAuth::Plugins

Defined in:
lib/better_auth/sso/plugin/saml_core.rb,
lib/better_auth/sso/plugin/saml_response.rb,
lib/better_auth/sso/plugin/saml_metadata_and_logout.rb,
lib/better_auth/sso/plugin/saml_validation_and_state.rb

Constant Summary collapse

SSO_SAML_SIGNATURE_ALGORITHMS =
{
  "rsa-sha1" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
  "rsa-sha256" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
  "rsa-sha384" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
  "rsa-sha512" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
  "ecdsa-sha256" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
  "ecdsa-sha384" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
  "ecdsa-sha512" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512",
  "sha1" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
  "sha256" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
  "sha384" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
  "sha512" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"
}.freeze
SSO_SAML_DIGEST_ALGORITHMS =
{
  "sha1" => "http://www.w3.org/2000/09/xmldsig#sha1",
  "sha256" => "http://www.w3.org/2001/04/xmlenc#sha256",
  "sha384" => "http://www.w3.org/2001/04/xmldsig-more#sha384",
  "sha512" => "http://www.w3.org/2001/04/xmlenc#sha512"
}.freeze
SSO_SAML_SECURE_SIGNATURE_ALGORITHMS =
(SSO_SAML_SIGNATURE_ALGORITHMS.values - ["http://www.w3.org/2000/09/xmldsig#rsa-sha1"]).uniq.freeze
SSO_SAML_SECURE_DIGEST_ALGORITHMS =
(SSO_SAML_DIGEST_ALGORITHMS.values - ["http://www.w3.org/2000/09/xmldsig#sha1"]).uniq.freeze
SSO_SAML_SECURE_KEY_ENCRYPTION_ALGORITHMS =
%w[
  http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p
  http://www.w3.org/2009/xmlenc11#rsa-oaep
].freeze
SSO_SAML_SECURE_DATA_ENCRYPTION_ALGORITHMS =
%w[
  http://www.w3.org/2001/04/xmlenc#aes128-cbc
  http://www.w3.org/2001/04/xmlenc#aes192-cbc
  http://www.w3.org/2001/04/xmlenc#aes256-cbc
  http://www.w3.org/2009/xmlenc11#aes128-gcm
  http://www.w3.org/2009/xmlenc11#aes192-gcm
  http://www.w3.org/2009/xmlenc11#aes256-gcm
].freeze
SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE =
256 * 1024
SSO_DEFAULT_MAX_SAML_METADATA_SIZE =
100 * 1024
SSO_SAML_RELAY_STATE_KEY_PREFIX =
"saml-relay-state:"
SSO_SAML_AUTHN_REQUEST_KEY_PREFIX =
"saml-authn-request:"
SSO_DEFAULT_AUTHN_REQUEST_TTL_MS =
5 * 60 * 1000
SSO_SAML_USED_ASSERTION_KEY_PREFIX =
"saml-used-assertion:"
SSO_DEFAULT_ASSERTION_TTL_MS =
15 * 60 * 1000
SSO_DEFAULT_CLOCK_SKEW_MS =
5 * 60 * 1000
SSO_SAML_SESSION_KEY_PREFIX =
"saml-session:"
SSO_SAML_SESSION_BY_ID_KEY_PREFIX =
"saml-session-by-id:"
SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX =
"saml-logout-request:"
SSO_SAML_STATUS_SUCCESS =
"urn:oasis:names:tc:SAML:2.0:status:Success"
SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS =
5 * 60 * 1000

Class Method Summary collapse

Class Method Details

.sso_assign_organization_membership(ctx, provider, user, config) ⇒ Object



144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/better_auth/sso/plugin/saml_response.rb', line 144

def sso_assign_organization_membership(ctx, provider, user, config)
  organization_id = provider["organizationId"]
  return if organization_id.to_s.empty?
  return if config.dig(:organization_provisioning, :disabled)
  return unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
  return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user.fetch("id")}])

  role = if config.dig(:organization_provisioning, :get_role).respond_to?(:call)
    config.dig(:organization_provisioning, :get_role).call(user: user, userInfo: {}, provider: provider)
  else
    config.dig(:organization_provisioning, :default_role) || config.dig(:organization_provisioning, :role) || "member"
  end
  ctx.context.adapter.create(model: "member", data: {organizationId: organization_id, userId: user.fetch("id"), role: role, createdAt: Time.now})
end

.sso_base64_xml?(value) ⇒ Boolean

Returns:

  • (Boolean)


75
76
77
78
79
# File 'lib/better_auth/sso/plugin/saml_validation_and_state.rb', line 75

def sso_base64_xml?(value)
  Base64.decode64(value.to_s).lstrip.start_with?("<")
rescue
  false
end

.sso_find_or_create_user(ctx, provider, user_info, config = {}) ⇒ Object



61
62
63
# File 'lib/better_auth/sso/plugin/saml_response.rb', line 61

def sso_find_or_create_user(ctx, provider, , config = {})
  sso_find_or_create_user_result(ctx, provider, , config).fetch(:user)
end

.sso_find_or_create_user_result(ctx, provider, user_info, config = {}) ⇒ Object



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/better_auth/sso/plugin/saml_response.rb', line 65

def sso_find_or_create_user_result(ctx, provider, , config = {})
   = normalize_hash()
  email = [:email].to_s.downcase
   = ([:id] || ["id"]).to_s
  provider_id = provider.fetch("providerId")
  storage_provider_id = provider["samlConfig"] ? provider_id : "sso:#{provider_id}"
   = .empty? ? nil : (
    ctx.context.internal_adapter.(, provider_id) ||
      ctx.context.internal_adapter.(, "sso:#{provider_id}")
  )
  if 
    user = ctx.context.internal_adapter.find_user_by_id(.fetch("userId"))
    created = false
  elsif (found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true))
    already_linked_provider = Array(found[:accounts]).any? do ||
      [provider_id, "sso:#{provider_id}"].include?(["providerId"])
    end
    if provider["samlConfig"]
      return {error: "account_not_linked"} unless already_linked_provider || sso_saml_trusted_provider?(ctx, provider, email)
    elsif !already_linked_provider && !sso_oidc_trusted_provider?(ctx, provider, email)
      return {error: "account_not_linked"}
    end

    user = found[:user]
    unless .empty?
      ctx.context.internal_adapter.(
        accountId: ,
        providerId: storage_provider_id,
        userId: user.fetch("id")
      )
    end
    oidc_config = sso_provider_config_hash(provider["oidcConfig"])
    if oidc_config[:override_user_info] || config[:default_override_user_info]
      update = {}
      update[:name] = [:name] if .key?(:name)
      update[:image] = [:image] if .key?(:image)
      update[:emailVerified] = !![:email_verified] if .key?(:email_verified)
      user = ctx.context.internal_adapter.update_user(user.fetch("id"), update) if update.any?
    end
    created = false
  else
    created = ctx.context.internal_adapter.create_user(
      email: email,
      name: [:name] || email,
      emailVerified: .key?(:email_verified) ? [:email_verified] : false,
      image: [:image]
    )
    ctx.context.internal_adapter.(
      accountId: .empty? ? created.fetch("id") : ,
      providerId: storage_provider_id,
      userId: created.fetch("id")
    )
    user = created
    created = true
  end
  sso_assign_organization_membership(ctx, provider, user, config)
  {user: user, created: created}
end

.sso_generate_saml_relay_state(ctx, state_data) ⇒ Object



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/better_auth/sso/plugin/saml_validation_and_state.rb', line 150

def sso_generate_saml_relay_state(ctx, state_data)
  ttl_ms = 10 * 60 * 1000
  relay_state = BetterAuth::Crypto.random_string(32)
  now_ms = (Time.now.to_f * 1000).to_i
  stored = state_data.each_with_object({}) { |(key, value), result| result[key.to_s] = value }.merge(
    "codeVerifier" => BetterAuth::Crypto.random_string(128),
    "expiresAt" => now_ms + ttl_ms
  )
  ctx.context.internal_adapter.create_verification_value(
    identifier: "#{SSO_SAML_RELAY_STATE_KEY_PREFIX}#{relay_state}",
    value: JSON.generate(stored),
    expiresAt: Time.at((now_ms + ttl_ms) / 1000.0)
  )
  ctx.set_signed_cookie("relay_state", relay_state, ctx.context.secret, path: "/", max_age: ttl_ms / 1000, http_only: true, same_site: "lax")
  relay_state
end

.sso_handle_saml_response(ctx, config = {}) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/better_auth/sso/plugin/saml_response.rb', line 7

def sso_handle_saml_response(ctx, config = {})
  provider = sso_find_saml_provider!(ctx, sso_fetch(ctx.params, :provider_id), config)
  relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
  state = sso_parse_saml_relay_state(ctx, relay_state) || {}
  raw_response = sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response)
  if ctx.method == "GET" && raw_response.to_s.empty?
    session = Routes.current_session(ctx, allow_nil: true)
    unless session
      return sso_redirect(ctx, sso_append_error("#{ctx.context.base_url}/error", "invalid_request"))
    end

    return sso_redirect(ctx, sso_safe_saml_callback_url(ctx, relay_state || sso_saml_callback_url(provider) || "/", provider.fetch("providerId")))
  end
  max_response_size = config.dig(:saml, :max_response_size) || SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE
  if raw_response.to_s.bytesize > max_response_size
    raise APIError.new("BAD_REQUEST", message: "SAML response exceeds maximum allowed size (#{max_response_size} bytes)")
  end
  in_response_to_result = sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
  return in_response_to_result if in_response_to_result.is_a?(Array)

  assertion = sso_parse_saml_response(raw_response, config, provider, ctx)
  assertion[:email_verified] = false unless config[:trust_email_verified]
  sso_validate_saml_timestamp!(sso_saml_timestamp_conditions(assertion), config)
  sso_validate_saml_response!(config, assertion, provider, ctx)
  sso_consume_saml_in_response_to(ctx, in_response_to_result)
  assertion_id = assertion[:id] || assertion["id"]
  unless assertion_id.to_s.empty?
    replay_key = "#{SSO_SAML_USED_ASSERTION_KEY_PREFIX}#{assertion_id}"
    if ctx.context.internal_adapter.find_verification_value(replay_key)
      callback_url = sso_safe_saml_callback_url(ctx, state["callbackURL"] || sso_saml_callback_url(provider) || "/", provider.fetch("providerId"))
      return sso_redirect(ctx, sso_append_error(callback_url, "replay_detected", "SAML assertion has already been used"))
    end
    ctx.context.internal_adapter.create_verification_value(identifier: replay_key, value: "used", expiresAt: sso_saml_assertion_replay_expires_at(assertion, config))
  end

  callback_url = sso_safe_saml_callback_url(ctx, state["callbackURL"] || sso_saml_callback_url(provider) || "/", provider.fetch("providerId"))
  email = (assertion[:email] || assertion["email"]).to_s.downcase
  if config[:disable_implicit_sign_up] && !state["requestSignUp"] && !ctx.context.internal_adapter.find_user_by_email(email)
    return sso_redirect(ctx, sso_append_error(callback_url, "signup disabled"))
  end

  result = sso_find_or_create_user_result(ctx, provider, assertion, config)
  return sso_redirect(ctx, sso_append_error(callback_url, result.fetch(:error))) if result[:error]

  user = result.fetch(:user)
  if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
    config[:provision_user].call(user: user, userInfo: assertion, provider: provider)
  end
  session = ctx.context.internal_adapter.create_session(user.fetch("id"))
  sso_store_saml_session(ctx, provider, assertion, session) if config.dig(:saml, :enable_single_logout)
  Cookies.set_session_cookie(ctx, {session: session, user: user})
  sso_redirect(ctx, callback_url)
end

.sso_normalize_saml_digest_algorithm(algorithm) ⇒ Object



128
129
130
# File 'lib/better_auth/sso/plugin/saml_validation_and_state.rb', line 128

def sso_normalize_saml_digest_algorithm(algorithm)
  SSO_SAML_DIGEST_ALGORITHMS.fetch(algorithm.to_s.downcase, algorithm.to_s)
end

.sso_normalize_saml_signature_algorithm(algorithm) ⇒ Object



124
125
126
# File 'lib/better_auth/sso/plugin/saml_validation_and_state.rb', line 124

def sso_normalize_saml_signature_algorithm(algorithm)
  SSO_SAML_SIGNATURE_ALGORITHMS.fetch(algorithm.to_s.downcase, algorithm.to_s)
end

.sso_oidc_trusted_provider?(ctx, provider, email) ⇒ Boolean

Returns:

  • (Boolean)


133
134
135
136
137
138
139
140
141
142
# File 'lib/better_auth/sso/plugin/saml_response.rb', line 133

def sso_oidc_trusted_provider?(ctx, provider, email)
  provider_id = provider.fetch("providerId")
  linking = ctx.context.options.[:account_linking] || {}
  return false if linking[:enabled] == false

  trusted_providers = Array(linking[:trusted_providers]).map(&:to_s)
  trusted_providers.include?(provider_id.to_s) ||
    trusted_providers.include?("sso:#{provider_id}") ||
    (provider["domainVerified"] && sso_email_domain_matches?(email, provider["domain"]))
end

.sso_parse_saml_logout_request(raw_request) ⇒ Object



246
247
248
249
250
251
252
253
254
255
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 246

def sso_parse_saml_logout_request(raw_request)
  xml = Base64.decode64(raw_request.to_s.gsub(/\s+/, ""))
  {
    id: xml[/\bID=['"]([^'"]+)['"]/, 1],
    name_id: xml[%r{<(?:\w+:)?NameID[^>]*>([^<]+)</(?:\w+:)?NameID>}, 1],
    session_index: xml[%r{<(?:\w+:)?SessionIndex[^>]*>([^<]+)</(?:\w+:)?SessionIndex>}, 1]
  }
rescue
  {}
end

.sso_parse_saml_logout_response(raw_response) ⇒ Object



291
292
293
294
295
296
297
298
299
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 291

def sso_parse_saml_logout_response(raw_response)
  xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
  {
    in_response_to: xml[/\bInResponseTo=['"]([^'"]+)['"]/, 1],
    status_code: xml[/<(?:\w+:)?StatusCode\b[^>]*\bValue=['"]([^'"]+)['"]/, 1]
  }
rescue
  {}
end

.sso_parse_saml_metadata_xml(xml) ⇒ Object



107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 107

def (xml)
  doc = REXML::Document.new(xml.to_s)
  root = doc.root
  {
    entity_id: root&.attributes&.[]("entityID"),
    cert: sso_saml_normalize_certificate((doc, "X509Certificate")),
    single_sign_on_service: (doc, "SingleSignOnService"),
    single_logout_service: (doc, "SingleLogoutService")
  }.compact
rescue
  {}
end

.sso_parse_saml_relay_state(ctx, relay_state) ⇒ Object



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/better_auth/sso/plugin/saml_validation_and_state.rb', line 167

def sso_parse_saml_relay_state(ctx, relay_state)
  state = sso_verify_state(relay_state, ctx.context.secret)
  return state if state

  verification = ctx.context.internal_adapter.find_verification_value("#{SSO_SAML_RELAY_STATE_KEY_PREFIX}#{relay_state}")
  return nil unless verification
  return nil unless sso_future_time?(verification.fetch("expiresAt"))

  parsed = JSON.parse(verification.fetch("value"))
  return nil if parsed["expiresAt"].to_i <= (Time.now.to_f * 1000).to_i

  parsed
rescue
  nil
end

.sso_parse_saml_response(value, config = {}, provider = nil, ctx = nil) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
# File 'lib/better_auth/sso/plugin/saml_validation_and_state.rb', line 7

def sso_parse_saml_response(value, config = {}, provider = nil, ctx = nil)
  parser = config.dig(:saml, :parse_response)
  if parser.respond_to?(:call)
    sso_validate_single_saml_assertion!(value) if sso_base64_xml?(value)
    parsed = parser.call(raw_response: value.to_s, provider: provider, context: ctx)
    return normalize_hash(parsed)
  end

  JSON.parse(Base64.decode64(value.to_s), symbolize_names: true)
rescue APIError
  raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
rescue
  raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
end

.sso_parse_saml_timestamp(value, error_message) ⇒ Object



61
62
63
64
65
# File 'lib/better_auth/sso/plugin/saml_validation_and_state.rb', line 61

def sso_parse_saml_timestamp(value, error_message)
  Time.parse(value.to_s).utc
rescue
  raise APIError.new("BAD_REQUEST", message: error_message)
end

.sso_process_saml_logout_request(ctx, provider, raw_request) ⇒ Object



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 205

def sso_process_saml_logout_request(ctx, provider, raw_request)
  data = sso_parse_saml_logout_request(raw_request)
  return data if data[:name_id].to_s.empty?

  session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{data[:name_id]}"
  verification = ctx.context.internal_adapter.find_verification_value(session_identifier)
  return data unless verification

  record = JSON.parse(verification.fetch("value"))
  session_token = record["sessionToken"]
  session_index_matches = data[:session_index].to_s.empty? || record["sessionIndex"].to_s.empty? || data[:session_index].to_s == record["sessionIndex"].to_s
  ctx.context.internal_adapter.delete_session(session_token) if session_token && session_index_matches
  ctx.context.internal_adapter.delete_verification_by_identifier(session_identifier)
  ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}") if session_token
  data
rescue
  {}
end

.sso_process_saml_logout_response(ctx, raw_response) ⇒ Object



233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 233

def sso_process_saml_logout_response(ctx, raw_response)
  data = sso_parse_saml_logout_response(raw_response)
  status_code = data[:status_code]
  if status_code && status_code != SSO_SAML_STATUS_SUCCESS
    raise APIError.new("BAD_REQUEST", message: "Logout failed at IdP")
  end

  in_response_to = data[:in_response_to]
  return if in_response_to.to_s.empty?

  ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX}#{in_response_to}")
end

.sso_safe_saml_callback_url(ctx, url, provider_id) ⇒ Object



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 323

def sso_safe_saml_callback_url(ctx, url, provider_id)
  app_origin = ctx.context.base_url
  callback_path = URI.parse("#{ctx.context.base_url}/sso/saml2/callback/#{URI.encode_www_form_component(provider_id)}").path
  acs_path = URI.parse("#{ctx.context.base_url}/sso/saml2/sp/acs/#{URI.encode_www_form_component(provider_id)}").path
  value = url.to_s
  return app_origin if value.empty?

  if value.start_with?("/") && !value.start_with?("//")
    parsed = URI.parse(value)
    return app_origin if [callback_path, acs_path].include?(parsed.path)
    return value
  end

  return app_origin unless ctx.context.trusted_origin?(value, allow_relative_paths: false)

  parsed = URI.parse(value)
  return app_origin if [callback_path, acs_path].include?(parsed.path)

  value
rescue
  app_origin
end

.sso_safe_slo_redirect_url(ctx, url, provider_id) ⇒ Object



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 301

def sso_safe_slo_redirect_url(ctx, url, provider_id)
  app_origin = ctx.context.base_url
  callback_path = URI.parse("#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}").path
  value = url.to_s
  return app_origin if value.empty?

  if value.start_with?("/") && !value.start_with?("//")
    parsed = URI.parse(value)
    return app_origin if parsed.path == callback_path
    return value
  end

  return app_origin unless ctx.context.trusted_origin?(value, allow_relative_paths: false)

  parsed = URI.parse(value)
  return app_origin if parsed.path == callback_path

  value
rescue
  app_origin
end

.sso_saml_acs_url(ctx, provider) ⇒ Object



74
75
76
77
78
79
80
81
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 74

def sso_saml_acs_url(ctx, provider)
  provider_id = provider.fetch("providerId")
  base_url = ctx.context.base_url
  configured = sso_provider_config_hash(provider["samlConfig"])[:callback_url].to_s
  return configured if sso_saml_acs_url?(configured)

  "#{base_url}/sso/saml2/sp/acs/#{URI.encode_www_form_component(provider_id)}"
end

.sso_saml_acs_url?(url) ⇒ Boolean

Returns:

  • (Boolean)


83
84
85
86
87
88
89
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 83

def sso_saml_acs_url?(url)
  return false if url.to_s.empty?

  URI.parse(url.to_s).path.include?("/sso/saml2/sp/acs")
rescue
  false
end

.sso_saml_callback_url(provider) ⇒ Object



161
162
163
164
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 161

def sso_saml_callback_url(provider)
  saml_config = sso_provider_config_hash(provider["samlConfig"])
  saml_config[:callback_url]
end

.sso_saml_idp_metadata(provider_or_config) ⇒ Object



91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 91

def (provider_or_config)
  saml_config = if provider_or_config.respond_to?(:key?) && (provider_or_config.key?("samlConfig") || provider_or_config.key?(:samlConfig))
    normalize_hash(provider_or_config["samlConfig"] || provider_or_config[:samlConfig] || {})
  else
    normalize_hash(provider_or_config || {})
  end
   = normalize_hash(saml_config[:idp_metadata] || {})
  xml = [:metadata] || saml_config[:metadata] || saml_config[:idp_metadata_xml]
  parsed = xml.to_s.strip.empty? ? {} : (xml)
  parsed[:entity_id] ||= [:entity_id] || [:entityID] || saml_config[:issuer]
  parsed[:cert] ||= [:cert] || saml_config[:cert]
  parsed[:single_sign_on_service] = ([:single_sign_on_service] || saml_config[:single_sign_on_service]) if parsed[:single_sign_on_service].to_a.empty?
  parsed[:single_logout_service] = ([:single_logout_service] || saml_config[:single_logout_service]) if parsed[:single_logout_service].to_a.empty?
  parsed
end

.sso_saml_logout_destination(provider) ⇒ Object



166
167
168
169
170
171
172
173
174
175
176
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 166

def sso_saml_logout_destination(provider)
  saml_config = sso_provider_config_hash(provider["samlConfig"])
  direct = saml_config[:single_logout_service] ||
    saml_config[:single_logout_service_url] ||
    saml_config[:idp_slo_service_url] ||
    saml_config[:logout_url]
  return direct unless direct.to_s.empty?

  service = sso_saml_preferred_service((saml_config)[:single_logout_service])
  normalize_hash(service || {})[:location]
end

.sso_saml_metadata_first_text(doc, element_name) ⇒ Object



133
134
135
136
137
138
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 133

def (doc, element_name)
  REXML::XPath.each(doc, "//*") do |element|
    return element.text.to_s.strip if element.name == element_name && !element.text.to_s.strip.empty?
  end
  nil
end

.sso_saml_metadata_services(doc, element_name) ⇒ Object



120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 120

def (doc, element_name)
  services = []
  REXML::XPath.each(doc, "//*") do |element|
    next unless element.name == element_name

    services << {
      binding: element.attributes["Binding"],
      location: element.attributes["Location"]
    }.compact
  end
  services
end

.sso_saml_metadata_services_from_config(value) ⇒ Object



140
141
142
143
144
145
146
147
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 140

def (value)
  Array(value).filter_map do |entry|
    data = normalize_hash(entry || {})
    next if data[:location].to_s.empty?

    {binding: data[:binding] || data[:Binding], location: data[:location] || data[:Location]}.compact
  end
end

.sso_saml_normalize_certificate(value) ⇒ Object



153
154
155
156
157
158
159
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 153

def sso_saml_normalize_certificate(value)
  cert = value.to_s.strip
  return nil if cert.empty?
  return cert if cert.include?("BEGIN CERTIFICATE")

  "-----BEGIN CERTIFICATE-----\n#{cert.scan(/.{1,64}/).join("\n")}\n-----END CERTIFICATE-----"
end

.sso_saml_post_form(action, saml_param, saml_value, relay_state = nil) ⇒ Object



372
373
374
375
376
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 372

def sso_saml_post_form(action, saml_param, saml_value, relay_state = nil)
  relay_input = relay_state.to_s.empty? ? "" : "<input type=\"hidden\" name=\"RelayState\" value=\"#{CGI.escapeHTML(relay_state.to_s)}\" />"
  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>"
  [200, {"content-type" => "text/html"}, [html]]
end

.sso_saml_preferred_service(services) ⇒ Object



149
150
151
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 149

def sso_saml_preferred_service(services)
  Array(services).find { |service| normalize_hash(service)[:binding].to_s.include?("HTTP-Redirect") } || Array(services).first
end

.sso_saml_signature_digest(signature_algorithm) ⇒ Object



359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 359

def sso_saml_signature_digest(signature_algorithm)
  case signature_algorithm.to_s
  when /sha512/i
    OpenSSL::Digest.new("SHA512")
  when /sha384/i
    OpenSSL::Digest.new("SHA384")
  when /sha1/i
    OpenSSL::Digest.new("SHA1")
  else
    OpenSSL::Digest.new("SHA256")
  end
end

.sso_saml_timestamp_conditions(assertion) ⇒ Object



67
68
69
70
71
72
73
# File 'lib/better_auth/sso/plugin/saml_validation_and_state.rb', line 67

def sso_saml_timestamp_conditions(assertion)
  assertion = normalize_hash(assertion || {})
  conditions = normalize_hash(assertion[:conditions] || {})
  conditions[:not_before] ||= assertion[:not_before] || assertion[:notBefore]
  conditions[:not_on_or_after] ||= assertion[:not_on_or_after] || assertion[:notOnOrAfter]
  conditions
end

.sso_saml_trusted_provider?(ctx, provider, email) ⇒ Boolean

Returns:

  • (Boolean)


124
125
126
127
128
129
130
131
# File 'lib/better_auth/sso/plugin/saml_response.rb', line 124

def sso_saml_trusted_provider?(ctx, provider, email)
  provider_id = provider.fetch("providerId")
  linking = ctx.context.options.[:account_linking] || {}
  return false if linking[:enabled] == false

  trusted = Array(linking[:trusted_providers]).map(&:to_s).include?(provider_id.to_s)
  trusted || (provider["domainVerified"] && sso_email_domain_matches?(email, provider["domain"]))
end

.sso_signed_saml_redirect_query(provider, query) ⇒ Object

Raises:

  • (APIError)


346
347
348
349
350
351
352
353
354
355
356
357
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 346

def sso_signed_saml_redirect_query(provider, query)
  saml_config = sso_provider_config_hash(provider["samlConfig"])
  private_key = saml_config.dig(:sp_metadata, :private_key) || saml_config[:private_key] || saml_config[:sp_private_key]
  raise APIError.new("BAD_REQUEST", message: "SAML Redirect signing requires privateKey") if private_key.to_s.empty?

  sig_alg = saml_config[:signature_algorithm] ? sso_normalize_saml_signature_algorithm(saml_config[:signature_algorithm]) : XMLSecurity::Document::RSA_SHA256
  signed = query.compact.merge(SigAlg: sig_alg)
  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? }
  signature_input = URI.encode_www_form(signed_payload)
  signed[:Signature] = Base64.strict_encode64(OpenSSL::PKey.read(private_key).sign(sso_saml_signature_digest(sig_alg), signature_input))
  signed
end

.sso_sp_metadata_xml(ctx, provider, config = {}) ⇒ Object



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 53

def (ctx, provider, config = {})
  provider_id = provider.fetch("providerId")
  saml_config = sso_provider_config_hash(provider["samlConfig"])
   = saml_config.dig(:sp_metadata, :metadata)
  return  unless .to_s.empty?

  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)}"
  acs_url = sso_saml_acs_url(ctx, provider)
  authn_requests_signed = !!saml_config[:authn_requests_signed]
  want_assertions_signed = saml_config.key?(:want_assertions_signed) ? !!saml_config[:want_assertions_signed] : true
  escaped_entity_id = CGI.escapeHTML(entity_id.to_s)
  escaped_acs_url = CGI.escapeHTML(acs_url.to_s)
  name_id_format = saml_config[:identifier_format].to_s.empty? ? "" : "<NameIDFormat>#{CGI.escapeHTML(saml_config[:identifier_format].to_s)}</NameIDFormat>"
  slo = if config.dig(:saml, :enable_single_logout)
    location = CGI.escapeHTML("#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}")
    "<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}\" />"
  end

  "<EntityDescriptor entityID=\"#{escaped_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=\"#{escaped_acs_url}\" index=\"0\" /></SPSSODescriptor></EntityDescriptor>"
end

.sso_store_saml_logout_request(ctx, provider, request_id, config) ⇒ Object



224
225
226
227
228
229
230
231
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 224

def sso_store_saml_logout_request(ctx, provider, request_id, config)
  ttl_ms = (config.dig(:saml, :logout_request_ttl) || SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS).to_i
  ctx.context.internal_adapter.create_verification_value(
    identifier: "#{SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX}#{request_id}",
    value: provider.fetch("providerId"),
    expiresAt: Time.now + (ttl_ms / 1000.0)
  )
end

.sso_store_saml_session(ctx, provider, assertion, session) ⇒ Object



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 178

def sso_store_saml_session(ctx, provider, assertion, session)
  name_id = assertion[:name_id] || assertion[:nameid] || assertion[:email]
  session_index = assertion[:session_index] || assertion[:sessionindex] || assertion[:id]
  return if name_id.to_s.empty? || session_index.to_s.empty?

  record = {
    providerId: provider.fetch("providerId"),
    sessionToken: session.fetch("token"),
    userId: session.fetch("userId"),
    nameId: name_id.to_s,
    sessionIndex: session_index.to_s
  }
  expires_at = session["expiresAt"] || Time.now + (SSO_DEFAULT_ASSERTION_TTL_MS / 1000.0)
  value = JSON.generate(record)
  session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{name_id}"
  ctx.context.internal_adapter.create_verification_value(
    identifier: session_identifier,
    value: value,
    expiresAt: expires_at
  )
  ctx.context.internal_adapter.create_verification_value(
    identifier: "#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session.fetch("token")}",
    value: session_identifier,
    expiresAt: expires_at
  )
end

.sso_validate_saml_algorithm_group!(algorithms, allowed:, secure:, deprecated:, on_deprecated:, label:) ⇒ Object



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/better_auth/sso/plugin/saml_validation_and_state.rb', line 132

def sso_validate_saml_algorithm_group!(algorithms, allowed:, secure:, deprecated:, on_deprecated:, label:)
  algorithms.each do |algorithm|
    if allowed
      next if allowed.include?(algorithm)

      raise APIError.new("BAD_REQUEST", message: "SAML #{label} algorithm not in allow-list: #{algorithm}")
    end

    if deprecated.include?(algorithm)
      raise APIError.new("BAD_REQUEST", message: "SAML response uses deprecated #{label} algorithm: #{algorithm}") if on_deprecated == "reject"
      next
    end
    next if secure.include?(algorithm)

    raise APIError.new("BAD_REQUEST", message: "SAML #{label} algorithm not recognized: #{algorithm}")
  end
end

.sso_validate_saml_algorithms!(xml, options = {}) ⇒ Object



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/better_auth/sso/plugin/saml_validation_and_state.rb', line 81

def sso_validate_saml_algorithms!(xml, options = {})
  on_deprecated = (options[:on_deprecated] || "warn").to_s
  signature_algorithms = xml.to_s.scan(/SignatureMethod[^>]+Algorithm=["']([^"']+)["']/).flatten.map { |algorithm| sso_normalize_saml_signature_algorithm(algorithm) }
  digest_algorithms = xml.to_s.scan(/DigestMethod[^>]+Algorithm=["']([^"']+)["']/).flatten.map { |algorithm| sso_normalize_saml_digest_algorithm(algorithm) }
  key_encryption_algorithms = xml.to_s.scan(/<[^\/>]*EncryptedKey\b[\s\S]*?EncryptionMethod[^>]+Algorithm=["']([^"']+)["']/).flatten
  data_encryption_algorithms = xml.to_s.scan(/<[^\/>]*EncryptedData\b[\s\S]*?EncryptionMethod[^>]+Algorithm=["']([^"']+)["']/).flatten

  sso_validate_saml_algorithm_group!(
    signature_algorithms,
    allowed: options[:allowed_signature_algorithms]&.map { |algorithm| sso_normalize_saml_signature_algorithm(algorithm) },
    secure: SSO_SAML_SECURE_SIGNATURE_ALGORITHMS,
    deprecated: ["http://www.w3.org/2000/09/xmldsig#rsa-sha1"],
    on_deprecated: on_deprecated,
    label: "signature"
  )
  sso_validate_saml_algorithm_group!(
    digest_algorithms,
    allowed: options[:allowed_digest_algorithms]&.map { |algorithm| sso_normalize_saml_digest_algorithm(algorithm) },
    secure: SSO_SAML_SECURE_DIGEST_ALGORITHMS,
    deprecated: ["http://www.w3.org/2000/09/xmldsig#sha1"],
    on_deprecated: on_deprecated,
    label: "digest"
  )
  sso_validate_saml_algorithm_group!(
    key_encryption_algorithms,
    allowed: options[:allowed_key_encryption_algorithms],
    secure: SSO_SAML_SECURE_KEY_ENCRYPTION_ALGORITHMS,
    deprecated: ["http://www.w3.org/2001/04/xmlenc#rsa-1_5"],
    on_deprecated: on_deprecated,
    label: "key encryption"
  )
  sso_validate_saml_algorithm_group!(
    data_encryption_algorithms,
    allowed: options[:allowed_data_encryption_algorithms],
    secure: SSO_SAML_SECURE_DATA_ENCRYPTION_ALGORITHMS,
    deprecated: ["http://www.w3.org/2001/04/xmlenc#tripledes-cbc"],
    on_deprecated: on_deprecated,
    label: "data encryption"
  )

  true
end

.sso_validate_saml_config!(saml_config, plugin_config = {}) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 7

def sso_validate_saml_config!(saml_config, plugin_config = {})
   = saml_config[:idp_metadata] || saml_config[:metadata] || saml_config[:idp_metadata_xml]
   = normalize_hash(saml_config[:idp_metadata] || {})
   = ![:metadata].to_s.empty? || !saml_config[:metadata].to_s.empty? || !saml_config[:idp_metadata_xml].to_s.empty?
  has_idp_sso_service = !Array([:single_sign_on_service] || saml_config[:single_sign_on_service]).empty?
   = plugin_config.dig(:saml, :max_metadata_size) || SSO_DEFAULT_MAX_SAML_METADATA_SIZE
  if .to_s.bytesize > 
    raise APIError.new("BAD_REQUEST", message: "IdP metadata exceeds maximum allowed size (#{} bytes)")
  end

  if saml_config[:entry_point].to_s.empty? && !has_idp_sso_service && !
    raise APIError.new("BAD_REQUEST", message: "SAML configuration requires either idpMetadata.metadata, idpMetadata.singleSignOnService, or a valid entryPoint URL")
  end
  sso_validate_url!(saml_config[:entry_point], "SAML entryPoint must be a valid URL") unless saml_config[:entry_point].to_s.empty?
  unless saml_config[:single_sign_on_service].to_s.empty?
    sso_validate_url!(saml_config[:single_sign_on_service], "SAML singleSignOnService must be a valid URL")
  end
  unless saml_config[:single_logout_service].to_s.empty?
    sso_validate_url!(saml_config[:single_logout_service], "SAML singleLogoutService must be a valid URL")
  end

  config_algorithm_xml = +""
  unless saml_config[:signature_algorithm].to_s.empty?
    config_algorithm_xml << "<ds:SignatureMethod Algorithm=\"#{saml_config[:signature_algorithm]}\"/>"
  end
  unless saml_config[:digest_algorithm].to_s.empty?
    config_algorithm_xml << "<ds:DigestMethod Algorithm=\"#{saml_config[:digest_algorithm]}\"/>"
  end
  sso_validate_saml_algorithms!(
    config_algorithm_xml,
    on_deprecated: plugin_config.dig(:saml, :algorithms, :on_deprecated) || saml_config[:on_deprecated_algorithm] || "warn",
    allowed_signature_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_signature_algorithms) || saml_config[:allowed_signature_algorithms],
    allowed_digest_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_digest_algorithms) || saml_config[:allowed_digest_algorithms],
    allowed_key_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_key_encryption_algorithms) || saml_config[:allowed_key_encryption_algorithms],
    allowed_data_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_data_encryption_algorithms) || saml_config[:allowed_data_encryption_algorithms]
  )
  sso_validate_saml_algorithms!(
    .to_s,
    on_deprecated: plugin_config.dig(:saml, :algorithms, :on_deprecated) || saml_config[:on_deprecated_algorithm] || "warn",
    allowed_signature_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_signature_algorithms) || saml_config[:allowed_signature_algorithms],
    allowed_digest_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_digest_algorithms) || saml_config[:allowed_digest_algorithms],
    allowed_key_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_key_encryption_algorithms) || saml_config[:allowed_key_encryption_algorithms],
    allowed_data_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_data_encryption_algorithms) || saml_config[:allowed_data_encryption_algorithms]
  )
end

.sso_validate_saml_redirect_signature(ctx, raw_message, signature, sig_alg) ⇒ Object



276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 276

def sso_validate_saml_redirect_signature(ctx, raw_message, signature, sig_alg)
  provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
  cert = (provider)[:cert]
  certificate = OpenSSL::X509::Certificate.new(cert.to_s)
  has_saml_request = sso_fetch(ctx.body, :saml_request) || sso_fetch(ctx.query, :saml_request)
  saml_param = has_saml_request ? "SAMLRequest" : "SAMLResponse"
  relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
  payload = [[saml_param, raw_message]]
  payload << ["RelayState", relay_state] unless relay_state.to_s.empty?
  payload << ["SigAlg", sig_alg]
  certificate.public_key.verify(sso_saml_signature_digest(sig_alg), Base64.decode64(signature.to_s), URI.encode_www_form(payload))
rescue
  false
end

.sso_validate_saml_response!(config, assertion, provider, ctx) ⇒ Object

Raises:

  • (APIError)


159
160
161
162
163
164
165
# File 'lib/better_auth/sso/plugin/saml_response.rb', line 159

def sso_validate_saml_response!(config, assertion, provider, ctx)
  validator = config.dig(:saml, :validate_response)
  return unless validator.respond_to?(:call)
  return if validator.call(response: assertion, provider: provider, context: ctx)

  raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
end

.sso_validate_saml_slo_signature!(ctx, raw_message, error_message) ⇒ Object



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/better_auth/sso/plugin/saml_metadata_and_logout.rb', line 257

def sso_validate_saml_slo_signature!(ctx, raw_message, error_message)
  signature = sso_fetch(ctx.body, :signature) || sso_fetch(ctx.query, :signature)
  sig_alg = sso_fetch(ctx.body, :sig_alg) || sso_fetch(ctx.query, :sig_alg)
  if !signature.to_s.empty? && !sig_alg.to_s.empty?
    return true if sso_validate_saml_redirect_signature(ctx, raw_message, signature, sig_alg)

    raise APIError.new("BAD_REQUEST", message: error_message)
  end

  xml = Base64.decode64(raw_message.to_s.gsub(/\s+/, ""))
  return true if xml.include?("<Signature") || xml.include?(":Signature")

  raise APIError.new("BAD_REQUEST", message: error_message)
rescue APIError
  raise
rescue
  raise APIError.new("BAD_REQUEST", message: error_message)
end

.sso_validate_saml_timestamp!(conditions, config = {}, now: Time.now.utc) ⇒ Object

Raises:

  • (APIError)


41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/better_auth/sso/plugin/saml_validation_and_state.rb', line 41

def sso_validate_saml_timestamp!(conditions, config = {}, now: Time.now.utc)
  conditions = normalize_hash(conditions || {})
  not_before = conditions[:not_before] || conditions[:notBefore]
  not_on_or_after = conditions[:not_on_or_after] || conditions[:notOnOrAfter]
  if not_before.to_s.empty? && not_on_or_after.to_s.empty?
    raise APIError.new("BAD_REQUEST", message: "SAML assertion missing required timestamp conditions") if config.dig(:saml, :require_timestamps)

    return true
  end

  clock_skew_seconds = ((config.dig(:saml, :clock_skew) || SSO_DEFAULT_CLOCK_SKEW_MS).to_f / 1000.0)
  parsed_not_before = sso_parse_saml_timestamp(not_before, "SAML assertion has invalid NotBefore timestamp") unless not_before.to_s.empty?
  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?

  raise APIError.new("BAD_REQUEST", message: "SAML assertion is not yet valid") if parsed_not_before && now < (parsed_not_before - clock_skew_seconds)
  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)

  true
end

.sso_validate_single_saml_assertion!(saml_response) ⇒ Object



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/better_auth/sso/plugin/saml_validation_and_state.rb', line 22

def sso_validate_single_saml_assertion!(saml_response)
  xml = Base64.decode64(saml_response.to_s)
  raise APIError.new("BAD_REQUEST", message: "Invalid base64-encoded SAML response") unless xml.include?("<")

  assertions = xml.scan(/<(?:\w+:)?Assertion(?:\s|>|\/)/).length
  encrypted_assertions = xml.scan(/<(?:\w+:)?EncryptedAssertion(?:\s|>|\/)/).length
  total = assertions + encrypted_assertions
  raise APIError.new("BAD_REQUEST", message: "SAML response contains no assertions") if total.zero?
  if total > 1
    raise APIError.new("BAD_REQUEST", message: "SAML response contains #{total} assertions, expected exactly 1")
  end

  true
rescue APIError
  raise
rescue
  raise APIError.new("BAD_REQUEST", message: "Invalid base64-encoded SAML response")
end