better_auth-sso 0.1.0 → 0.2.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.
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "base64"
4
+ require "cgi"
4
5
  require "json"
6
+ require "jwt"
5
7
  require "net/http"
6
8
  require "openssl"
9
+ require "resolv"
7
10
  require "securerandom"
11
+ require "time"
8
12
  require "uri"
13
+ require "zlib"
9
14
 
10
15
  module BetterAuth
11
16
  module Plugins
@@ -17,7 +22,12 @@ module BetterAuth
17
22
  SSO_ERROR_CODES = {
18
23
  "PROVIDER_NOT_FOUND" => "No provider found",
19
24
  "INVALID_STATE" => "Invalid state",
20
- "SAML_RESPONSE_REPLAYED" => "SAML response has already been used"
25
+ "SAML_RESPONSE_REPLAYED" => "SAML response has already been used",
26
+ "SINGLE_LOGOUT_NOT_ENABLED" => "Single Logout is not enabled",
27
+ "INVALID_LOGOUT_REQUEST" => "Invalid LogoutRequest",
28
+ "INVALID_LOGOUT_RESPONSE" => "Invalid LogoutResponse",
29
+ "LOGOUT_FAILED_AT_IDP" => "Logout failed at IdP",
30
+ "IDP_SLO_NOT_SUPPORTED" => "IdP does not support Single Logout Service"
21
31
  }.freeze
22
32
 
23
33
  SSO_SAML_SIGNATURE_ALGORITHMS = {
@@ -55,47 +65,70 @@ module BetterAuth
55
65
  http://www.w3.org/2009/xmlenc11#aes192-gcm
56
66
  http://www.w3.org/2009/xmlenc11#aes256-gcm
57
67
  ].freeze
68
+ SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024
69
+ SSO_DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024
70
+ SSO_SAML_AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:"
71
+ SSO_DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000
72
+ SSO_SAML_USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:"
73
+ SSO_DEFAULT_ASSERTION_TTL_MS = 15 * 60 * 1000
74
+ SSO_DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000
75
+ SSO_SAML_SESSION_KEY_PREFIX = "saml-session:"
76
+ SSO_SAML_SESSION_BY_ID_KEY_PREFIX = "saml-session-by-id:"
77
+ SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX = "saml-logout-request:"
78
+ SSO_SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
79
+ SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS = 5 * 60 * 1000
58
80
 
59
81
  def sso(options = {})
60
82
  config = normalize_hash(options)
83
+ if defined?(BetterAuth::SSO::SAML) && defined?(BetterAuth::SSO::SAMLHooks)
84
+ config = BetterAuth::SSO::SAMLHooks.merge_options(BetterAuth::SSO::SAML.sso_options, config)
85
+ 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
61
105
  Plugin.new(
62
106
  id: "sso",
63
- init: ->(_ctx) { {options: {advanced: {disable_origin_check: ["/sso/saml2/callback", "/sso/saml2/sp/acs"]}}} },
107
+ init: ->(_ctx) { {options: {advanced: {disable_origin_check: ["/sso/saml2/callback", "/sso/saml2/sp/acs", "/sso/saml2/sp/slo"]}}} },
64
108
  schema: sso_schema(config),
65
- endpoints: {
66
- sp_metadata: sso_sp_metadata_endpoint,
67
- register_sso_provider: sso_register_provider_endpoint,
68
- sign_in_sso: sso_sign_in_endpoint(config),
69
- callback_sso: sso_oidc_callback_endpoint,
70
- callback_sso_saml: sso_saml_callback_endpoint(config),
71
- acs_endpoint: sso_saml_acs_endpoint(config),
72
- list_sso_providers: sso_list_providers_endpoint,
73
- get_sso_provider: sso_get_provider_endpoint,
74
- update_sso_provider: sso_update_provider_endpoint,
75
- delete_sso_provider: sso_delete_provider_endpoint,
76
- request_domain_verification: sso_request_domain_verification_endpoint(config),
77
- verify_domain: sso_verify_domain_endpoint(config)
78
- },
109
+ endpoints: endpoints,
79
110
  error_codes: SSO_ERROR_CODES,
80
111
  options: config
81
112
  )
82
113
  end
83
114
 
84
115
  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
85
128
  {
86
129
  ssoProvider: {
87
130
  model_name: config[:model_name] || "ssoProviders",
88
- fields: {
89
- issuer: {type: "string", required: true},
90
- oidcConfig: {type: "string", required: false},
91
- samlConfig: {type: "string", required: false},
92
- userId: {type: "string", required: true},
93
- providerId: {type: "string", required: true, unique: true},
94
- domain: {type: "string", required: true},
95
- domainVerified: {type: "boolean", required: false, default_value: false},
96
- domainVerificationToken: {type: "string", required: false},
97
- organizationId: {type: "string", required: false}
98
- }
131
+ fields: fields
99
132
  }
100
133
  }
101
134
  end
@@ -155,7 +188,9 @@ module BetterAuth
155
188
  uri.to_s
156
189
  else
157
190
  issuer_uri = URI(issuer.to_s)
158
- URI.join("#{issuer_uri.scheme}://#{issuer_uri.host}", value.to_s).to_s
191
+ issuer_base = issuer_uri.to_s.sub(%r{/+\z}, "")
192
+ endpoint = value.to_s.sub(%r{\A/+}, "")
193
+ "#{issuer_base}/#{endpoint}"
159
194
  end
160
195
  if trusted_origin && !trusted_origin.call(normalized)
161
196
  raise APIError.new("BAD_REQUEST", message: "OIDC discovery endpoint is not trusted")
@@ -166,30 +201,50 @@ module BetterAuth
166
201
  raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document")
167
202
  end
168
203
 
169
- def sso_register_provider_endpoint
204
+ def sso_register_provider_endpoint(config = {})
170
205
  Endpoint.new(path: "/sso/register", method: "POST") do |ctx|
171
206
  session = Routes.current_session(ctx)
172
207
  body = normalize_hash(ctx.body)
173
208
  provider_id = body[:provider_id].to_s
174
209
  raise APIError.new("BAD_REQUEST", message: "providerId is required") if provider_id.empty?
210
+
211
+ limit = sso_provider_limit(session.fetch(:user), config)
212
+ if limit.to_i.zero?
213
+ raise APIError.new("FORBIDDEN", message: "SSO provider registration is disabled")
214
+ end
215
+ providers = ctx.context.adapter.find_many(model: "ssoProvider", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
216
+ if providers.length >= limit.to_i
217
+ raise APIError.new("FORBIDDEN", message: "You have reached the maximum number of SSO providers")
218
+ end
219
+
220
+ sso_validate_url!(body[:issuer], "Invalid issuer. Must be a valid URL")
221
+ sso_validate_organization_membership!(ctx, session.fetch(:user).fetch("id"), body[:organization_id]) if body[:organization_id]
175
222
  if ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id}])
176
- raise APIError.new("BAD_REQUEST", message: "Provider already exists")
223
+ raise APIError.new("UNPROCESSABLE_ENTITY", message: "SSO provider with this providerId already exists")
177
224
  end
178
225
 
226
+ oidc_config = normalize_hash(body[:oidc_config] || {})
227
+ oidc_config = sso_hydrate_oidc_config(body[:issuer], oidc_config, ctx) if oidc_config.any? && !oidc_config[:skip_discovery]
228
+ oidc_config[:override_user_info] = !!(body[:override_user_info] || config[:default_override_user_info]) if oidc_config.any?
229
+ saml_config = normalize_hash(body[:saml_config] || {})
230
+ sso_validate_saml_config!(saml_config, config) unless saml_config.empty?
231
+
179
232
  provider = ctx.context.adapter.create(
180
233
  model: "ssoProvider",
181
234
  data: {
182
235
  providerId: provider_id,
183
236
  issuer: body[:issuer].to_s,
184
237
  domain: body[:domain].to_s.downcase,
185
- oidcConfig: body[:oidc_config],
186
- samlConfig: body[:saml_config],
238
+ oidcConfig: oidc_config.empty? ? nil : oidc_config,
239
+ samlConfig: saml_config.empty? ? nil : saml_config,
187
240
  userId: session.fetch(:user).fetch("id"),
188
241
  organizationId: body[:organization_id],
189
- domainVerified: body[:domain_verified] || false
242
+ domainVerified: false
190
243
  }
191
244
  )
192
- ctx.json(sso_sanitize_provider(provider, ctx.context))
245
+ response = sso_sanitize_provider(provider, ctx.context)
246
+ response[:redirectURI] = sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId"))
247
+ ctx.json(response)
193
248
  end
194
249
  end
195
250
 
@@ -204,38 +259,52 @@ module BetterAuth
204
259
  end
205
260
 
206
261
  def sso_get_provider_endpoint
207
- Endpoint.new(path: "/sso/providers/:providerId", method: "GET") do |ctx|
262
+ Endpoint.new(path: "/sso/get-provider", method: "GET") do |ctx|
208
263
  session = Routes.current_session(ctx)
209
- provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
210
- raise APIError.new("FORBIDDEN", message: "Access denied") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
264
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.query, :provider_id) || sso_fetch(ctx.params, :provider_id))
265
+ raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
211
266
 
212
267
  ctx.json(sso_sanitize_provider(provider, ctx.context))
213
268
  end
214
269
  end
215
270
 
216
271
  def sso_update_provider_endpoint
217
- Endpoint.new(path: "/sso/providers/:providerId", method: "PATCH") do |ctx|
272
+ Endpoint.new(path: "/sso/update-provider", method: "POST") do |ctx|
218
273
  session = Routes.current_session(ctx)
219
- provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
220
- raise APIError.new("FORBIDDEN", message: "Access denied") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
221
-
222
274
  body = normalize_hash(ctx.body)
275
+ provider = sso_find_provider!(ctx, sso_fetch(body, :provider_id) || sso_fetch(ctx.params, :provider_id))
276
+ raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
277
+
278
+ if !body.key?(:issuer) && !body.key?(:domain) && !body.key?(:oidc_config) && !body.key?(:saml_config)
279
+ raise APIError.new("BAD_REQUEST", message: "No fields provided for update")
280
+ end
281
+ sso_validate_url!(body[:issuer], "Invalid issuer. Must be a valid URL") if body.key?(:issuer)
223
282
  update = {}
224
283
  update[:issuer] = body[:issuer] if body.key?(:issuer)
225
284
  update[:domain] = body[:domain].to_s.downcase if body.key?(:domain)
226
- update[:domainVerified] = false if body.key?(:domain)
227
- update[:oidcConfig] = body[:oidc_config] if body.key?(:oidc_config)
228
- update[:samlConfig] = body[:saml_config] if body.key?(:saml_config)
285
+ update[:domainVerified] = false if body.key?(:domain) && body[:domain].to_s.downcase != provider["domain"].to_s
286
+ if body.key?(:oidc_config)
287
+ current = normalize_hash(provider["oidcConfig"] || {})
288
+ raise APIError.new("BAD_REQUEST", message: "Cannot update OIDC config for a provider that doesn't have OIDC configured") if current.empty?
289
+
290
+ update[:oidcConfig] = current.merge(normalize_hash(body[:oidc_config]))
291
+ end
292
+ if body.key?(:saml_config)
293
+ current = normalize_hash(provider["samlConfig"] || {})
294
+ raise APIError.new("BAD_REQUEST", message: "Cannot update SAML config for a provider that doesn't have SAML configured") if current.empty?
295
+
296
+ update[:samlConfig] = current.merge(normalize_hash(body[:saml_config]))
297
+ end
229
298
  updated = ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: update)
230
299
  ctx.json(sso_sanitize_provider(updated, ctx.context))
231
300
  end
232
301
  end
233
302
 
234
303
  def sso_delete_provider_endpoint
235
- Endpoint.new(path: "/sso/providers/:providerId", method: "DELETE") do |ctx|
304
+ Endpoint.new(path: "/sso/delete-provider", method: "POST") do |ctx|
236
305
  session = Routes.current_session(ctx)
237
- provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
238
- raise APIError.new("FORBIDDEN", message: "Access denied") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
306
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.body, :provider_id) || sso_fetch(ctx.params, :provider_id))
307
+ raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
239
308
 
240
309
  ctx.context.adapter.delete(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}])
241
310
  ctx.json({success: true})
@@ -245,7 +314,18 @@ module BetterAuth
245
314
  def sso_sign_in_endpoint(config = {})
246
315
  Endpoint.new(path: "/sign-in/sso", method: "POST") do |ctx|
247
316
  body = normalize_hash(ctx.body)
248
- provider = sso_select_provider(ctx, body)
317
+ provider = sso_select_provider(ctx, body, config)
318
+ provider_type = body[:provider_type].to_s
319
+ if provider_type == "oidc" && !provider["oidcConfig"]
320
+ raise APIError.new("BAD_REQUEST", message: "OIDC provider is not configured")
321
+ end
322
+ if provider_type == "saml" && !provider["samlConfig"]
323
+ raise APIError.new("BAD_REQUEST", message: "SAML provider is not configured")
324
+ end
325
+ if config.dig(:domain_verification, :enabled) && !(provider.key?("domainVerified") && provider["domainVerified"])
326
+ raise APIError.new("UNAUTHORIZED", message: "Provider domain has not been verified")
327
+ end
328
+
249
329
  state_data = {
250
330
  providerId: provider.fetch("providerId"),
251
331
  callbackURL: body[:callback_url] || "/",
@@ -254,34 +334,91 @@ module BetterAuth
254
334
  requestSignUp: body[:request_sign_up]
255
335
  }
256
336
 
257
- if provider["samlConfig"]
337
+ if provider["oidcConfig"] && provider_type != "saml"
338
+ provider = sso_ensure_runtime_oidc_provider(ctx, provider, config)
339
+ state = BetterAuth::Crypto.sign_jwt(state_data.merge(sso_oidc_pkce_state(provider)), ctx.context.secret, expires_in: 600)
340
+ url = sso_oidc_authorization_url(provider, ctx, state, config, body)
341
+ elsif provider["samlConfig"]
258
342
  relay_state = BetterAuth::Crypto.sign_jwt(state_data.merge(nonce: SecureRandom.hex(8)), ctx.context.secret, expires_in: 600)
259
343
  url = sso_saml_authorization_url(provider, relay_state, ctx, config)
344
+ sso_store_saml_authn_request(ctx, provider, url, config)
260
345
  else
261
- state = BetterAuth::Crypto.sign_jwt(state_data, ctx.context.secret, expires_in: 600)
262
- url = sso_oidc_authorization_url(provider, ctx, state)
346
+ raise APIError.new("BAD_REQUEST", message: "OIDC provider is not configured")
263
347
  end
264
348
  ctx.json({url: url, redirect: true})
265
349
  end
266
350
  end
267
351
 
268
- def sso_oidc_callback_endpoint
352
+ def sso_oidc_callback_endpoint(config = {})
269
353
  Endpoint.new(path: "/sso/callback/:providerId", method: "GET") do |ctx|
354
+ sso_handle_oidc_callback(ctx, config, sso_fetch(ctx.params, :provider_id))
355
+ end
356
+ end
357
+
358
+ def sso_oidc_shared_callback_endpoint(config = {})
359
+ Endpoint.new(path: "/sso/callback", method: "GET") do |ctx|
270
360
  state = sso_verify_state(ctx.query[:state] || ctx.query["state"], ctx.context.secret)
271
361
  next ctx.redirect("#{ctx.context.base_url}/error?error=invalid_state") unless state
272
362
 
273
- provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
274
- oidc_config = normalize_hash(provider["oidcConfig"] || {})
275
- token_callback = oidc_config[:get_token]
276
- user_callback = oidc_config[:get_user_info]
277
- tokens = token_callback ? token_callback.call(code: ctx.query[:code] || ctx.query["code"]) : {accessToken: "access-token"}
278
- user_info = user_callback ? user_callback.call(tokens) : {}
279
- user = sso_find_or_create_user(ctx, provider, user_info)
280
- session = ctx.context.internal_adapter.create_session(user.fetch("id"))
281
- Cookies.set_session_cookie(ctx, {session: session, user: user})
282
- redirect_to = (state["newUserURL"] && !state["newUserURL"].to_s.empty?) ? state["newUserURL"] : state["callbackURL"]
283
- sso_redirect(ctx, redirect_to || "/")
363
+ sso_handle_oidc_callback(ctx, config, state["providerId"], state: state)
364
+ end
365
+ end
366
+
367
+ def sso_handle_oidc_callback(ctx, config, provider_id, state: nil)
368
+ state ||= sso_verify_state(ctx.query[:state] || ctx.query["state"], ctx.context.secret)
369
+ return ctx.redirect("#{ctx.context.base_url}/error?error=invalid_state") unless state
370
+
371
+ callback_url = state["callbackURL"] || "/"
372
+ error_url = state["errorURL"] || callback_url
373
+ if ctx.query[:error] || ctx.query["error"]
374
+ error = ctx.query[:error] || ctx.query["error"]
375
+ description = ctx.query[:error_description] || ctx.query["error_description"]
376
+ return sso_redirect(ctx, sso_append_error(error_url, error, description))
377
+ end
378
+
379
+ provider = sso_callback_provider(ctx, config, provider_id)
380
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "provider not found")) unless provider
381
+ if config.dig(:domain_verification, :enabled) && !(provider.key?("domainVerified") && provider["domainVerified"])
382
+ raise APIError.new("UNAUTHORIZED", message: "Provider domain has not been verified")
383
+ end
384
+
385
+ provider = sso_ensure_runtime_oidc_provider(ctx, provider, config)
386
+ oidc_config = normalize_hash(provider["oidcConfig"] || {})
387
+ oidc_config[:issuer] ||= provider["issuer"]
388
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "provider not found")) if oidc_config.empty?
389
+
390
+ tokens = sso_oidc_tokens(ctx, provider, oidc_config, state, config)
391
+ unless tokens
392
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "token_response_not_found"))
393
+ end
394
+ if oidc_config[:user_info_endpoint].to_s.empty? && tokens[:id_token] && oidc_config[:jwks_endpoint].to_s.empty?
395
+ begin
396
+ provider = sso_ensure_runtime_oidc_provider(ctx, provider, config, require_jwks: true)
397
+ oidc_config = normalize_hash(provider["oidcConfig"] || {})
398
+ oidc_config[:issuer] ||= provider["issuer"]
399
+ rescue APIError
400
+ # Fall through to the upstream callback error when JWKS is still unavailable.
401
+ end
402
+ end
403
+ user_info = sso_oidc_user_info(ctx, oidc_config, tokens, config)
404
+ if user_info[:_sso_error]
405
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", user_info[:_sso_error]))
406
+ end
407
+ if user_info[:email].to_s.empty? || user_info[:id].to_s.empty?
408
+ return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "missing_user_info"))
284
409
  end
410
+ if config[:disable_implicit_sign_up] && !state["requestSignUp"] && !ctx.context.internal_adapter.find_user_by_email(user_info[:email].to_s.downcase)
411
+ return sso_redirect(ctx, sso_append_error(error_url, "signup disabled"))
412
+ end
413
+
414
+ result = sso_find_or_create_user_result(ctx, provider, user_info, config)
415
+ if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
416
+ config[:provision_user].call(user: result.fetch(:user), userInfo: user_info, token: tokens, provider: provider)
417
+ end
418
+ session = ctx.context.internal_adapter.create_session(result.fetch(:user).fetch("id"))
419
+ Cookies.set_session_cookie(ctx, {session: session, user: result.fetch(:user)})
420
+ redirect_to = (result.fetch(:created) && state["newUserURL"].to_s != "") ? state["newUserURL"] : callback_url
421
+ sso_redirect(ctx, redirect_to || "/")
285
422
  end
286
423
 
287
424
  def sso_saml_callback_endpoint(config)
@@ -296,10 +433,10 @@ module BetterAuth
296
433
  end
297
434
  end
298
435
 
299
- def sso_sp_metadata_endpoint
436
+ def sso_sp_metadata_endpoint(config = {})
300
437
  Endpoint.new(path: "/sso/saml2/sp/metadata", method: "GET") do |ctx|
301
438
  provider = sso_find_provider!(ctx, sso_fetch(ctx.query, :provider_id))
302
- metadata = "<EntityDescriptor entityID=\"#{ctx.context.base_url}/sso/saml2/sp/metadata\"><SPSSODescriptor /></EntityDescriptor>"
439
+ metadata = sso_sp_metadata_xml(ctx, provider, config)
303
440
  if (ctx.query[:format] || ctx.query["format"]) == "json"
304
441
  ctx.json({providerId: provider.fetch("providerId"), metadata: metadata})
305
442
  else
@@ -309,28 +446,113 @@ module BetterAuth
309
446
  end
310
447
  end
311
448
 
449
+ def sso_saml_slo_endpoint(config = {})
450
+ Endpoint.new(path: "/sso/saml2/sp/slo/:providerId", method: ["GET", "POST"], metadata: {allowed_media_types: ["application/json", "application/x-www-form-urlencoded"]}) do |ctx|
451
+ raise APIError.new("BAD_REQUEST", message: "Single Logout is not enabled") unless config.dig(:saml, :enable_single_logout)
452
+
453
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
454
+ relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
455
+ if sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response)
456
+ sso_process_saml_logout_response(ctx, sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response))
457
+ Cookies.delete_session_cookie(ctx)
458
+ next sso_redirect(ctx, sso_safe_slo_redirect_url(ctx, relay_state, provider.fetch("providerId")))
459
+ end
460
+
461
+ 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>")
463
+ if sso_fetch(ctx.body, :saml_request)
464
+ next sso_saml_post_form(sso_saml_logout_destination(provider), "SAMLResponse", response, relay_state)
465
+ end
466
+
467
+ sso_redirect(ctx, "#{sso_saml_logout_destination(provider)}?#{URI.encode_www_form(SAMLResponse: response, RelayState: relay_state)}")
468
+ end
469
+ end
470
+
471
+ def sso_initiate_slo_endpoint(config = {})
472
+ Endpoint.new(path: "/sso/saml2/logout/:providerId", method: "POST") do |ctx|
473
+ raise APIError.new("BAD_REQUEST", message: "Single Logout is not enabled") unless config.dig(:saml, :enable_single_logout)
474
+
475
+ session = Routes.current_session(ctx)
476
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
477
+ destination = sso_saml_logout_destination(provider)
478
+ if destination.to_s.empty?
479
+ raise APIError.new("BAD_REQUEST", message: "IdP does not support Single Logout Service")
480
+ end
481
+
482
+ relay_state = sso_fetch(ctx.body, :callback_url) || ctx.context.base_url
483
+ session_token = session.fetch(:session).fetch("token")
484
+ user_email = session.fetch(:user).fetch("email")
485
+ saml_session_key = ctx.context.internal_adapter.find_verification_value("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}")&.fetch("value")
486
+ saml_session = saml_session_key && ctx.context.internal_adapter.find_verification_value(saml_session_key)
487
+ saml_record = saml_session ? JSON.parse(saml_session.fetch("value")) : {}
488
+ name_id = saml_record["nameId"] || user_email
489
+ session_index = saml_record["sessionIndex"]
490
+
491
+ request_id = "_#{SecureRandom.hex(16)}"
492
+ session_index_xml = session_index.to_s.empty? ? "" : "<samlp:SessionIndex>#{CGI.escapeHTML(session_index.to_s)}</samlp:SessionIndex>"
493
+ 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
+ sso_store_saml_logout_request(ctx, provider, request_id, config)
495
+ ctx.context.internal_adapter.delete_verification_by_identifier(saml_session_key) if saml_session_key
496
+ ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}")
497
+ ctx.context.internal_adapter.delete_session(session_token)
498
+ Cookies.delete_session_cookie(ctx)
499
+ sso_redirect(ctx, "#{destination}?#{URI.encode_www_form(SAMLRequest: request, RelayState: relay_state)}")
500
+ end
501
+ end
502
+
312
503
  def sso_request_domain_verification_endpoint(config)
313
504
  Endpoint.new(path: "/sso/request-domain-verification", method: "POST") do |ctx|
314
- Routes.current_session(ctx)
505
+ session = Routes.current_session(ctx)
315
506
  provider = sso_find_provider!(ctx, normalize_hash(ctx.body)[:provider_id])
316
- token = "_better-auth-sso-verification-#{provider.fetch("providerId")}-#{SecureRandom.hex(16)}"
317
- updated = ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: {domainVerificationToken: token, domainVerified: false})
318
- config.dig(:domain_verification, :request)&.call(provider: updated, token: token, context: ctx)
319
- ctx.json({success: true, token: token}, status: 201)
507
+ sso_authorize_domain_verification!(ctx, provider, session.fetch(:user).fetch("id"))
508
+ if provider.key?("domainVerified") && provider["domainVerified"]
509
+ raise APIError.new("CONFLICT", message: "Domain has already been verified", code: "DOMAIN_VERIFIED")
510
+ end
511
+
512
+ identifier = sso_domain_verification_identifier(config, provider.fetch("providerId"))
513
+ active = ctx.context.internal_adapter.find_verification_value(identifier)
514
+ if active && sso_future_time?(active.fetch("expiresAt"))
515
+ next ctx.json({domainVerificationToken: active.fetch("value")}, status: 201)
516
+ end
517
+
518
+ token = SecureRandom.alphanumeric(24)
519
+ ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: token, expiresAt: Time.now + (7 * 24 * 60 * 60))
520
+ config.dig(:domain_verification, :request)&.call(provider: provider, token: token, context: ctx)
521
+ ctx.json({domainVerificationToken: token}, status: 201)
320
522
  end
321
523
  end
322
524
 
323
525
  def sso_verify_domain_endpoint(config)
324
526
  Endpoint.new(path: "/sso/verify-domain", method: "POST") do |ctx|
325
- Routes.current_session(ctx)
527
+ session = Routes.current_session(ctx)
326
528
  provider = sso_find_provider!(ctx, normalize_hash(ctx.body)[:provider_id])
327
- token = provider["domainVerificationToken"].to_s
328
- verifier = config.dig(:domain_verification, :verify)
329
- verified = verifier ? verifier.call(domain: provider.fetch("domain"), token: token, provider: provider, context: ctx) : true
330
- raise APIError.new("BAD_REQUEST", message: "Unable to verify domain ownership") unless verified
529
+ sso_authorize_domain_verification!(ctx, provider, session.fetch(:user).fetch("id"))
530
+ if provider.key?("domainVerified") && provider["domainVerified"]
531
+ raise APIError.new("CONFLICT", message: "Domain has already been verified", code: "DOMAIN_VERIFIED")
532
+ end
331
533
 
332
- ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: {domainVerified: true, domainVerificationToken: nil})
333
- ctx.json({success: true})
534
+ identifier = sso_domain_verification_identifier(config, provider.fetch("providerId"))
535
+ if identifier.length > 63
536
+ raise APIError.new("BAD_REQUEST", message: "Verification identifier exceeds the DNS label limit of 63 characters", code: "IDENTIFIER_TOO_LONG")
537
+ end
538
+ active = ctx.context.internal_adapter.find_verification_value(identifier)
539
+ if !active || !sso_future_time?(active.fetch("expiresAt"))
540
+ raise APIError.new("NOT_FOUND", message: "No pending domain verification exists", code: "NO_PENDING_VERIFICATION")
541
+ end
542
+
543
+ hostname = sso_hostname_from_domain(provider.fetch("domain"))
544
+ raise APIError.new("BAD_REQUEST", message: "Invalid domain", code: "INVALID_DOMAIN") if hostname.to_s.empty?
545
+
546
+ records = sso_resolve_txt_records("#{identifier}.#{hostname}", config)
547
+ expected = "#{identifier}=#{active.fetch("value")}"
548
+ unless records.flatten.any? { |record| record.to_s.include?(expected) }
549
+ raise APIError.new("BAD_GATEWAY", message: "Unable to verify domain ownership. Try again later", code: "DOMAIN_VERIFICATION_FAILED")
550
+ end
551
+
552
+ ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: {domainVerified: true})
553
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
554
+ ctx.set_status(204)
555
+ nil
334
556
  end
335
557
  end
336
558
 
@@ -338,34 +560,67 @@ module BetterAuth
338
560
  provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
339
561
  relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
340
562
  state = sso_verify_state(relay_state, ctx.context.secret) || {}
341
- assertion = sso_parse_saml_response(sso_fetch(ctx.body, :saml_response), config, provider, ctx)
563
+ raw_response = sso_fetch(ctx.body, :saml_response)
564
+ max_response_size = config.dig(:saml, :max_response_size) || SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE
565
+ if raw_response.to_s.bytesize > max_response_size
566
+ raise APIError.new("BAD_REQUEST", message: "SAML response exceeds maximum allowed size (#{max_response_size} bytes)")
567
+ end
568
+ in_response_to_error = sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
569
+ return in_response_to_error if in_response_to_error
570
+
571
+ assertion = sso_parse_saml_response(raw_response, config, provider, ctx)
572
+ assertion[:email_verified] = false unless config[:trust_email_verified]
573
+ sso_validate_saml_timestamp!(sso_saml_timestamp_conditions(assertion), config)
342
574
  sso_validate_saml_response!(config, assertion, provider, ctx)
343
575
  assertion_id = assertion[:id] || assertion["id"] || assertion[:email]
344
- replay_key = "sso-saml-assertion:#{provider.fetch("providerId")}:#{assertion_id}"
576
+ replay_key = "#{SSO_SAML_USED_ASSERTION_KEY_PREFIX}#{assertion_id}"
345
577
  if ctx.context.internal_adapter.find_verification_value(replay_key)
346
578
  raise APIError.new("BAD_REQUEST", message: SSO_ERROR_CODES.fetch("SAML_RESPONSE_REPLAYED"))
347
579
  end
348
- ctx.context.internal_adapter.create_verification_value(identifier: replay_key, value: "used", expiresAt: Time.now + 300)
580
+ ctx.context.internal_adapter.create_verification_value(identifier: replay_key, value: "used", expiresAt: sso_saml_assertion_replay_expires_at(assertion, config))
349
581
 
350
- user = sso_find_or_create_user(ctx, provider, assertion, config)
351
- session = ctx.context.internal_adapter.create_session(user.fetch("id"))
352
- Cookies.set_session_cookie(ctx, {session: session, user: user})
353
582
  callback_url = state["callbackURL"] || "/"
354
583
  callback_url = "/" unless ctx.context.trusted_origin?(callback_url, allow_relative_paths: true)
584
+ email = (assertion[:email] || assertion["email"]).to_s.downcase
585
+ if config[:disable_implicit_sign_up] && !state["requestSignUp"] && !ctx.context.internal_adapter.find_user_by_email(email)
586
+ return sso_redirect(ctx, sso_append_error(callback_url, "signup disabled"))
587
+ end
588
+
589
+ result = sso_find_or_create_user_result(ctx, provider, assertion, config)
590
+ user = result.fetch(:user)
591
+ if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
592
+ config[:provision_user].call(user: user, userInfo: assertion, provider: provider)
593
+ end
594
+ session = ctx.context.internal_adapter.create_session(user.fetch("id"))
595
+ sso_store_saml_session(ctx, provider, assertion, session) if config.dig(:saml, :enable_single_logout)
596
+ Cookies.set_session_cookie(ctx, {session: session, user: user})
355
597
  sso_redirect(ctx, callback_url)
356
598
  end
357
599
 
358
600
  def sso_find_or_create_user(ctx, provider, user_info, config = {})
601
+ sso_find_or_create_user_result(ctx, provider, user_info, config).fetch(:user)
602
+ end
603
+
604
+ def sso_find_or_create_user_result(ctx, provider, user_info, config = {})
359
605
  user_info = normalize_hash(user_info)
360
606
  email = user_info[:email].to_s.downcase
361
607
  found = ctx.context.internal_adapter.find_user_by_email(email)
362
- user = if found
363
- found[:user]
608
+ if found
609
+ user = found[:user]
610
+ oidc_config = normalize_hash(provider["oidcConfig"] || {})
611
+ if oidc_config[:override_user_info] || config[:default_override_user_info]
612
+ update = {}
613
+ update[:name] = user_info[:name] if user_info.key?(:name)
614
+ update[:image] = user_info[:image] if user_info.key?(:image)
615
+ update[:emailVerified] = !!user_info[:email_verified] if user_info.key?(:email_verified)
616
+ user = ctx.context.internal_adapter.update_user(user.fetch("id"), update) if update.any?
617
+ end
618
+ created = false
364
619
  else
365
620
  created = ctx.context.internal_adapter.create_user(
366
621
  email: email,
367
622
  name: user_info[:name] || email,
368
- emailVerified: user_info.key?(:email_verified) ? user_info[:email_verified] : true,
623
+ emailVerified: user_info.key?(:email_verified) ? user_info[:email_verified] : false,
369
624
  image: user_info[:image]
370
625
  )
371
626
  ctx.context.internal_adapter.create_account(
@@ -373,10 +628,11 @@ module BetterAuth
373
628
  providerId: "sso:#{provider.fetch("providerId")}",
374
629
  userId: created.fetch("id")
375
630
  )
376
- created
631
+ user = created
632
+ created = true
377
633
  end
378
634
  sso_assign_organization_membership(ctx, provider, user, config)
379
- user
635
+ {user: user, created: created}
380
636
  end
381
637
 
382
638
  def sso_validate_saml_response!(config, assertion, provider, ctx)
@@ -387,26 +643,201 @@ module BetterAuth
387
643
  raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
388
644
  end
389
645
 
646
+ def sso_validate_saml_config!(saml_config, plugin_config = {})
647
+ metadata = saml_config[:idp_metadata] || saml_config[:metadata] || saml_config[:idp_metadata_xml]
648
+ max_metadata_size = plugin_config.dig(:saml, :max_metadata_size) || SSO_DEFAULT_MAX_SAML_METADATA_SIZE
649
+ if metadata.to_s.bytesize > max_metadata_size
650
+ raise APIError.new("BAD_REQUEST", message: "IdP metadata exceeds maximum allowed size (#{max_metadata_size} bytes)")
651
+ end
652
+
653
+ if saml_config[:entry_point].to_s.empty? && saml_config[:single_sign_on_service].to_s.empty? && metadata.to_s.empty?
654
+ raise APIError.new("BAD_REQUEST", message: "SAML config must include entryPoint, singleSignOnService, or IdP metadata")
655
+ end
656
+
657
+ sso_validate_saml_algorithms!(
658
+ metadata.to_s,
659
+ on_deprecated: plugin_config.dig(:saml, :algorithms, :on_deprecated) || saml_config[:on_deprecated_algorithm] || "warn",
660
+ allowed_signature_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_signature_algorithms) || saml_config[:allowed_signature_algorithms],
661
+ allowed_digest_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_digest_algorithms) || saml_config[:allowed_digest_algorithms],
662
+ allowed_key_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_key_encryption_algorithms) || saml_config[:allowed_key_encryption_algorithms],
663
+ allowed_data_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_data_encryption_algorithms) || saml_config[:allowed_data_encryption_algorithms]
664
+ )
665
+ end
666
+
667
+ def sso_sp_metadata_xml(ctx, provider, config = {})
668
+ provider_id = provider.fetch("providerId")
669
+ 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)}"
672
+ authn_requests_signed = !!saml_config[:authn_requests_signed]
673
+ want_assertions_signed = saml_config.key?(:want_assertions_signed) ? !!saml_config[:want_assertions_signed] : true
674
+ slo = if config.dig(:saml, :enable_single_logout)
675
+ location = "#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}"
676
+ "<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
+ end
678
+
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>"
680
+ end
681
+
682
+ def sso_saml_logout_destination(provider)
683
+ saml_config = normalize_hash(provider["samlConfig"] || {})
684
+ direct = saml_config[:single_logout_service] ||
685
+ saml_config[:single_logout_service_url] ||
686
+ saml_config[:idp_slo_service_url] ||
687
+ saml_config[:logout_url]
688
+ return direct unless direct.to_s.empty?
689
+
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]
698
+ end
699
+
700
+ def sso_store_saml_session(ctx, provider, assertion, session)
701
+ name_id = assertion[:name_id] || assertion[:nameid] || assertion[:email]
702
+ session_index = assertion[:session_index] || assertion[:sessionindex] || assertion[:id]
703
+ return if name_id.to_s.empty? || session_index.to_s.empty?
704
+
705
+ record = {
706
+ providerId: provider.fetch("providerId"),
707
+ sessionToken: session.fetch("token"),
708
+ userId: session.fetch("userId"),
709
+ nameId: name_id.to_s,
710
+ sessionIndex: session_index.to_s
711
+ }
712
+ expires_at = session["expiresAt"] || Time.now + (SSO_DEFAULT_ASSERTION_TTL_MS / 1000.0)
713
+ value = JSON.generate(record)
714
+ session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{name_id}"
715
+ ctx.context.internal_adapter.create_verification_value(
716
+ identifier: session_identifier,
717
+ value: value,
718
+ expiresAt: expires_at
719
+ )
720
+ ctx.context.internal_adapter.create_verification_value(
721
+ identifier: "#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session.fetch("token")}",
722
+ value: session_identifier,
723
+ expiresAt: expires_at
724
+ )
725
+ end
726
+
727
+ def sso_process_saml_logout_request(ctx, provider, raw_request)
728
+ data = sso_parse_saml_logout_request(raw_request)
729
+ return if data[:name_id].to_s.empty?
730
+
731
+ session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{data[:name_id]}"
732
+ verification = ctx.context.internal_adapter.find_verification_value(session_identifier)
733
+ return unless verification
734
+
735
+ record = JSON.parse(verification.fetch("value"))
736
+ session_token = record["sessionToken"]
737
+ session_index_matches = data[:session_index].to_s.empty? || record["sessionIndex"].to_s.empty? || data[:session_index].to_s == record["sessionIndex"].to_s
738
+ ctx.context.internal_adapter.delete_session(session_token) if session_token && session_index_matches
739
+ ctx.context.internal_adapter.delete_verification_by_identifier(session_identifier)
740
+ ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}") if session_token
741
+ rescue
742
+ nil
743
+ end
744
+
745
+ def sso_store_saml_logout_request(ctx, provider, request_id, config)
746
+ ttl_ms = (config.dig(:saml, :logout_request_ttl) || SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS).to_i
747
+ ctx.context.internal_adapter.create_verification_value(
748
+ identifier: "#{SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX}#{request_id}",
749
+ value: provider.fetch("providerId"),
750
+ expiresAt: Time.now + (ttl_ms / 1000.0)
751
+ )
752
+ end
753
+
754
+ def sso_process_saml_logout_response(ctx, raw_response)
755
+ data = sso_parse_saml_logout_response(raw_response)
756
+ status_code = data[:status_code]
757
+ if status_code && status_code != SSO_SAML_STATUS_SUCCESS
758
+ raise APIError.new("BAD_REQUEST", message: "Logout failed at IdP")
759
+ end
760
+
761
+ in_response_to = data[:in_response_to]
762
+ return if in_response_to.to_s.empty?
763
+
764
+ ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX}#{in_response_to}")
765
+ end
766
+
767
+ def sso_parse_saml_logout_request(raw_request)
768
+ xml = Base64.decode64(raw_request.to_s.gsub(/\s+/, ""))
769
+ {
770
+ name_id: xml[%r{<(?:\w+:)?NameID[^>]*>([^<]+)</(?:\w+:)?NameID>}, 1],
771
+ session_index: xml[%r{<(?:\w+:)?SessionIndex[^>]*>([^<]+)</(?:\w+:)?SessionIndex>}, 1]
772
+ }
773
+ rescue
774
+ {}
775
+ end
776
+
777
+ def sso_parse_saml_logout_response(raw_response)
778
+ xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
779
+ {
780
+ in_response_to: xml[/\bInResponseTo=['"]([^'"]+)['"]/, 1],
781
+ status_code: xml[/<(?:\w+:)?StatusCode\b[^>]*\bValue=['"]([^'"]+)['"]/, 1]
782
+ }
783
+ rescue
784
+ {}
785
+ end
786
+
787
+ def sso_safe_slo_redirect_url(ctx, url, provider_id)
788
+ app_origin = ctx.context.base_url
789
+ callback_path = URI.parse("#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}").path
790
+ value = url.to_s
791
+ return app_origin if value.empty?
792
+
793
+ if value.start_with?("/") && !value.start_with?("//")
794
+ parsed = URI.parse(value)
795
+ return app_origin if parsed.path == callback_path
796
+ return value
797
+ end
798
+
799
+ return app_origin unless ctx.context.trusted_origin?(value, allow_relative_paths: false)
800
+
801
+ parsed = URI.parse(value)
802
+ return app_origin if parsed.path == callback_path
803
+
804
+ value
805
+ rescue
806
+ app_origin
807
+ end
808
+
809
+ def sso_saml_post_form(action, saml_param, saml_value, relay_state = nil)
810
+ relay_input = relay_state.to_s.empty? ? "" : "<input type=\"hidden\" name=\"RelayState\" value=\"#{CGI.escapeHTML(relay_state.to_s)}\" />"
811
+ 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>"
812
+ [200, {"content-type" => "text/html"}, [html]]
813
+ end
814
+
390
815
  def sso_assign_organization_membership(ctx, provider, user, config)
391
816
  organization_id = provider["organizationId"]
392
817
  return if organization_id.to_s.empty?
393
- return unless provider["domainVerified"]
394
- return unless sso_email_domain_matches?(user["email"].to_s.split("@").last.to_s.downcase, provider["domain"])
818
+ return if config.dig(:organization_provisioning, :disabled)
395
819
  return unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
396
820
  return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user.fetch("id")}])
397
821
 
398
- role = config.dig(:organization_provisioning, :role) || "member"
822
+ role = if config.dig(:organization_provisioning, :get_role).respond_to?(:call)
823
+ config.dig(:organization_provisioning, :get_role).call(user: user, userInfo: {}, provider: provider)
824
+ else
825
+ config.dig(:organization_provisioning, :default_role) || config.dig(:organization_provisioning, :role) || "member"
826
+ end
399
827
  ctx.context.adapter.create(model: "member", data: {organizationId: organization_id, userId: user.fetch("id"), role: role, createdAt: Time.now})
400
828
  end
401
829
 
402
830
  def sso_parse_saml_response(value, config = {}, provider = nil, ctx = nil)
403
831
  parser = config.dig(:saml, :parse_response)
404
832
  if parser.respond_to?(:call)
833
+ sso_validate_single_saml_assertion!(value) if sso_base64_xml?(value)
405
834
  parsed = parser.call(raw_response: value.to_s, provider: provider, context: ctx)
406
835
  return normalize_hash(parsed)
407
836
  end
408
837
 
409
838
  JSON.parse(Base64.decode64(value.to_s), symbolize_names: true)
839
+ rescue APIError
840
+ raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
410
841
  rescue
411
842
  raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
412
843
  end
@@ -430,6 +861,46 @@ module BetterAuth
430
861
  raise APIError.new("BAD_REQUEST", message: "Invalid base64-encoded SAML response")
431
862
  end
432
863
 
864
+ def sso_validate_saml_timestamp!(conditions, config = {}, now: Time.now.utc)
865
+ conditions = normalize_hash(conditions || {})
866
+ not_before = conditions[:not_before] || conditions[:notBefore]
867
+ not_on_or_after = conditions[:not_on_or_after] || conditions[:notOnOrAfter]
868
+ if not_before.to_s.empty? && not_on_or_after.to_s.empty?
869
+ raise APIError.new("BAD_REQUEST", message: "SAML assertion missing required timestamp conditions") if config.dig(:saml, :require_timestamps)
870
+
871
+ return true
872
+ end
873
+
874
+ clock_skew_seconds = ((config.dig(:saml, :clock_skew) || SSO_DEFAULT_CLOCK_SKEW_MS).to_f / 1000.0)
875
+ parsed_not_before = sso_parse_saml_timestamp(not_before, "SAML assertion has invalid NotBefore timestamp") unless not_before.to_s.empty?
876
+ 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?
877
+
878
+ raise APIError.new("BAD_REQUEST", message: "SAML assertion is not yet valid") if parsed_not_before && now < (parsed_not_before - clock_skew_seconds)
879
+ 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)
880
+
881
+ true
882
+ end
883
+
884
+ def sso_parse_saml_timestamp(value, error_message)
885
+ Time.parse(value.to_s).utc
886
+ rescue
887
+ raise APIError.new("BAD_REQUEST", message: error_message)
888
+ end
889
+
890
+ def sso_saml_timestamp_conditions(assertion)
891
+ assertion = normalize_hash(assertion || {})
892
+ conditions = normalize_hash(assertion[:conditions] || {})
893
+ conditions[:not_before] ||= assertion[:not_before] || assertion[:notBefore]
894
+ conditions[:not_on_or_after] ||= assertion[:not_on_or_after] || assertion[:notOnOrAfter]
895
+ conditions
896
+ end
897
+
898
+ def sso_base64_xml?(value)
899
+ Base64.decode64(value.to_s).lstrip.start_with?("<")
900
+ rescue
901
+ false
902
+ end
903
+
433
904
  def sso_validate_saml_algorithms!(xml, options = {})
434
905
  on_deprecated = (options[:on_deprecated] || "warn").to_s
435
906
  signature_algorithms = xml.to_s.scan(/SignatureMethod[^>]+Algorithm=["']([^"']+)["']/).flatten.map { |algorithm| sso_normalize_saml_signature_algorithm(algorithm) }
@@ -505,16 +976,26 @@ module BetterAuth
505
976
  nil
506
977
  end
507
978
 
508
- def sso_oidc_authorization_url(provider, ctx, state)
979
+ def sso_oidc_authorization_url(provider, ctx, state, plugin_config = {}, body = {})
509
980
  config = normalize_hash(provider["oidcConfig"] || {})
510
981
  endpoint = config[:authorization_endpoint] || config[:authorization_url]
982
+ raise APIError.new("BAD_REQUEST", message: "Invalid OIDC configuration. Authorization URL not found.") if endpoint.to_s.empty?
983
+
984
+ scopes = Array(body[:scopes] || config[:scopes] || config[:scope] || ["openid", "email", "profile", "offline_access"])
511
985
  query = {
512
986
  client_id: config[:client_id],
513
987
  response_type: "code",
514
- redirect_uri: "#{ctx.context.base_url}/sso/callback/#{provider.fetch("providerId")}",
515
- scope: Array(config[:scope] || config[:scopes] || ["openid", "email", "profile"]).join(" "),
988
+ redirect_uri: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
989
+ scope: scopes.join(" "),
516
990
  state: state
517
- }
991
+ }.compact
992
+ login_hint = body[:login_hint] || body[:email]
993
+ query[:login_hint] = login_hint if login_hint
994
+ code_verifier = sso_decode_state(state, ctx.context.secret)&.fetch("codeVerifier", nil)
995
+ if code_verifier
996
+ query[:code_challenge] = sso_base64_urlsafe(OpenSSL::Digest::SHA256.digest(code_verifier))
997
+ query[:code_challenge_method] = "S256"
998
+ end
518
999
  "#{endpoint}?#{URI.encode_www_form(query)}"
519
1000
  end
520
1001
 
@@ -532,22 +1013,386 @@ module BetterAuth
532
1013
  "#{config[:entry_point]}?#{URI.encode_www_form(query)}"
533
1014
  end
534
1015
 
535
- def sso_select_provider(ctx, body)
1016
+ def sso_store_saml_authn_request(ctx, provider, url, config)
1017
+ return if config.dig(:saml, :enable_in_response_to_validation) == false
1018
+
1019
+ request_id = sso_extract_saml_request_id(url)
1020
+ return if request_id.to_s.empty?
1021
+
1022
+ ttl_ms = (config.dig(:saml, :request_ttl) || SSO_DEFAULT_AUTHN_REQUEST_TTL_MS).to_i
1023
+ now_ms = (Time.now.to_f * 1000).to_i
1024
+ expires_at_ms = now_ms + ttl_ms
1025
+ record = {
1026
+ id: request_id,
1027
+ providerId: provider.fetch("providerId"),
1028
+ createdAt: now_ms,
1029
+ expiresAt: expires_at_ms
1030
+ }
1031
+ ctx.context.internal_adapter.create_verification_value(
1032
+ identifier: "#{SSO_SAML_AUTHN_REQUEST_KEY_PREFIX}#{request_id}",
1033
+ value: JSON.generate(record),
1034
+ expiresAt: Time.at(expires_at_ms / 1000.0)
1035
+ )
1036
+ end
1037
+
1038
+ def sso_extract_saml_request_id(url)
1039
+ query = URI.decode_www_form(URI.parse(url.to_s).query.to_s).to_h
1040
+ encoded = query["SAMLRequest"]
1041
+ return nil if encoded.to_s.empty?
1042
+
1043
+ xml = Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(Base64.decode64(encoded))
1044
+ xml[/\bID=['"]([^'"]+)['"]/, 1]
1045
+ rescue
1046
+ nil
1047
+ end
1048
+
1049
+ def sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
1050
+ return nil if config.dig(:saml, :enable_in_response_to_validation) == false
1051
+
1052
+ in_response_to = sso_extract_saml_in_response_to(raw_response)
1053
+ if in_response_to && !in_response_to.empty?
1054
+ identifier = "#{SSO_SAML_AUTHN_REQUEST_KEY_PREFIX}#{in_response_to}"
1055
+ verification = ctx.context.internal_adapter.find_verification_value(identifier)
1056
+ record = sso_parse_saml_authn_request_record(verification&.fetch("value", nil))
1057
+ if !record || record["expiresAt"].to_i < (Time.now.to_f * 1000).to_i
1058
+ return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Unknown or expired request ID"))
1059
+ end
1060
+
1061
+ if record["providerId"] != provider.fetch("providerId")
1062
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
1063
+ return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Provider mismatch"))
1064
+ end
1065
+
1066
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
1067
+ elsif config.dig(:saml, :allow_idp_initiated) == false
1068
+ return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "unsolicited_response", "IdP-initiated SSO not allowed"))
1069
+ end
1070
+
1071
+ nil
1072
+ end
1073
+
1074
+ def sso_parse_saml_authn_request_record(value)
1075
+ JSON.parse(value.to_s)
1076
+ rescue
1077
+ nil
1078
+ end
1079
+
1080
+ def sso_saml_assertion_replay_expires_at(assertion, config = {})
1081
+ timestamp = sso_saml_timestamp_conditions(assertion)[:not_on_or_after]
1082
+ parsed = Time.parse(timestamp.to_s) if timestamp
1083
+ clock_skew_seconds = ((config.dig(:saml, :clock_skew) || SSO_DEFAULT_CLOCK_SKEW_MS).to_f / 1000.0)
1084
+ return parsed + clock_skew_seconds if parsed && parsed + clock_skew_seconds > Time.now
1085
+
1086
+ ttl_ms = (config.dig(:saml, :assertion_ttl) || SSO_DEFAULT_ASSERTION_TTL_MS).to_i
1087
+ Time.now + (ttl_ms / 1000.0)
1088
+ rescue
1089
+ Time.now + (SSO_DEFAULT_ASSERTION_TTL_MS / 1000.0)
1090
+ end
1091
+
1092
+ def sso_extract_saml_in_response_to(raw_response)
1093
+ xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
1094
+ xml[/\bInResponseTo=['"]([^'"]+)['"]/, 1]
1095
+ rescue
1096
+ nil
1097
+ end
1098
+
1099
+ def sso_select_provider(ctx, body, config = {})
1100
+ provider_id = body[:provider_id].to_s
1101
+ issuer = body[:issuer].to_s
1102
+ organization_slug = body[:organization_slug].to_s
1103
+ domain = (body[:domain] || body[:email].to_s.split("@").last).to_s.downcase
1104
+ if config[:default_sso]
1105
+ provider = sso_default_provider(config, provider_id: provider_id, domain: domain)
1106
+ return provider if provider
1107
+ end
1108
+
536
1109
  providers = ctx.context.adapter.find_many(model: "ssoProvider")
537
- provider = if body[:provider_id]
538
- providers.find { |entry| entry["providerId"] == body[:provider_id].to_s }
539
- elsif body[:issuer]
540
- providers.find { |entry| entry["issuer"] == body[:issuer].to_s }
541
- else
542
- domain = body[:email].to_s.split("@").last.to_s.downcase
543
- providers.find { |entry| sso_email_domain_matches?(domain, entry["domain"]) }
1110
+ provider = if !provider_id.empty?
1111
+ providers.find { |entry| entry["providerId"] == provider_id }
1112
+ elsif !issuer.empty?
1113
+ providers.find { |entry| entry["issuer"] == issuer }
1114
+ elsif !organization_slug.empty?
1115
+ organization = ctx.context.adapter.find_one(model: "organization", where: [{field: "slug", value: organization_slug}])
1116
+ providers.find { |entry| entry["organizationId"] == organization&.fetch("id", nil) }
1117
+ elsif !domain.empty?
1118
+ providers.find { |entry| entry["domain"].to_s.downcase == domain } ||
1119
+ providers.find { |entry| sso_email_domain_matches?(domain, entry["domain"]) }
544
1120
  end
545
1121
  raise APIError.new("NOT_FOUND", message: SSO_ERROR_CODES.fetch("PROVIDER_NOT_FOUND")) unless provider
546
1122
 
547
1123
  provider
548
1124
  end
549
1125
 
1126
+ def sso_callback_provider(ctx, config, provider_id)
1127
+ if config[:default_sso]
1128
+ provider = sso_default_provider(config, provider_id: provider_id.to_s, domain: "")
1129
+ return provider if provider
1130
+ end
1131
+
1132
+ ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id.to_s}])
1133
+ end
1134
+
1135
+ def sso_oidc_tokens(ctx, provider, oidc_config, state, plugin_config)
1136
+ token_callback = oidc_config[:get_token]
1137
+ if token_callback.respond_to?(:call)
1138
+ return normalize_hash(token_callback.call(
1139
+ code: ctx.query[:code] || ctx.query["code"],
1140
+ codeVerifier: state["codeVerifier"],
1141
+ redirectURI: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
1142
+ provider: provider,
1143
+ context: ctx
1144
+ ))
1145
+ end
1146
+
1147
+ token_endpoint = oidc_config[:token_endpoint]
1148
+ return nil if token_endpoint.to_s.empty?
1149
+
1150
+ sso_exchange_oidc_code(
1151
+ token_endpoint: token_endpoint,
1152
+ code: ctx.query[:code] || ctx.query["code"],
1153
+ code_verifier: state["codeVerifier"],
1154
+ redirect_uri: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
1155
+ client_id: oidc_config[:client_id],
1156
+ client_secret: oidc_config[:client_secret],
1157
+ authentication: oidc_config[:token_endpoint_authentication]
1158
+ )
1159
+ rescue
1160
+ nil
1161
+ end
1162
+
1163
+ def sso_exchange_oidc_code(token_endpoint:, code:, code_verifier:, redirect_uri:, client_id:, client_secret:, authentication:)
1164
+ uri = URI(token_endpoint.to_s)
1165
+ request = Net::HTTP::Post.new(uri)
1166
+ form = {
1167
+ grant_type: "authorization_code",
1168
+ code: code,
1169
+ redirect_uri: redirect_uri,
1170
+ client_id: client_id,
1171
+ code_verifier: code_verifier
1172
+ }.compact
1173
+ if authentication.to_s == "client_secret_post"
1174
+ form[:client_secret] = client_secret
1175
+ elsif client_secret.to_s != ""
1176
+ request.basic_auth(client_id.to_s, client_secret.to_s)
1177
+ end
1178
+ request.set_form_data(form)
1179
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
1180
+ return nil unless response.is_a?(Net::HTTPSuccess)
1181
+
1182
+ normalize_hash(JSON.parse(response.body))
1183
+ end
1184
+
1185
+ def sso_oidc_user_info(ctx, oidc_config, tokens, plugin_config)
1186
+ user_callback = oidc_config[:get_user_info]
1187
+ raw = if user_callback.respond_to?(:call)
1188
+ user_callback.call(tokens)
1189
+ elsif oidc_config[:user_info_endpoint]
1190
+ sso_fetch_oidc_user_info(oidc_config[:user_info_endpoint], tokens[:access_token])
1191
+ elsif tokens[:id_token]
1192
+ return {_sso_error: "jwks_endpoint_not_found"} if oidc_config[:jwks_endpoint].to_s.empty?
1193
+
1194
+ sso_validate_oidc_id_token(
1195
+ tokens[:id_token],
1196
+ jwks_endpoint: oidc_config[:jwks_endpoint],
1197
+ audience: oidc_config[:client_id],
1198
+ issuer: oidc_config[:issuer],
1199
+ fetch: plugin_config[:oidc_jwks_fetch]
1200
+ ) || {_sso_error: "token_not_verified"}
1201
+ else
1202
+ {}
1203
+ end
1204
+ raw = normalize_hash(raw || {})
1205
+ return raw if raw[:_sso_error]
1206
+
1207
+ mapping = normalize_hash(oidc_config[:mapping] || {})
1208
+ extra_fields = normalize_hash(mapping[:extra_fields] || {}).each_with_object({}) do |(target, source), result|
1209
+ result[target] = raw[normalize_key(source)] || raw[source.to_s]
1210
+ end
1211
+ extra_fields.merge(
1212
+ id: raw[normalize_key(mapping[:id] || "sub")] || raw[:id],
1213
+ email: raw[normalize_key(mapping[:email] || "email")],
1214
+ email_verified: plugin_config[:trust_email_verified] ? raw[normalize_key(mapping[:email_verified] || "email_verified")] : false,
1215
+ name: raw[normalize_key(mapping[:name] || "name")],
1216
+ image: raw[normalize_key(mapping[:image] || "picture")]
1217
+ )
1218
+ end
1219
+
1220
+ def sso_fetch_oidc_user_info(endpoint, access_token)
1221
+ uri = URI(endpoint.to_s)
1222
+ request = Net::HTTP::Get.new(uri)
1223
+ request["authorization"] = "Bearer #{access_token}"
1224
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
1225
+ return {} unless response.is_a?(Net::HTTPSuccess)
1226
+
1227
+ JSON.parse(response.body)
1228
+ rescue
1229
+ {}
1230
+ end
1231
+
1232
+ def sso_validate_oidc_id_token(token, jwks_endpoint:, audience:, issuer:, fetch: nil)
1233
+ jwks = sso_fetch_oidc_jwks(jwks_endpoint, fetch: fetch)
1234
+ payload, = ::JWT.decode(
1235
+ token.to_s,
1236
+ nil,
1237
+ true,
1238
+ algorithms: %w[RS256 RS384 RS512 ES256 ES384 ES512],
1239
+ jwks: jwks,
1240
+ aud: audience,
1241
+ verify_aud: true,
1242
+ iss: issuer,
1243
+ verify_iss: true
1244
+ )
1245
+ payload
1246
+ rescue
1247
+ nil
1248
+ end
1249
+
1250
+ def sso_fetch_oidc_jwks(jwks_endpoint, fetch: nil)
1251
+ if fetch.respond_to?(:call)
1252
+ return normalize_hash(fetch.call(jwks_endpoint))
1253
+ end
1254
+
1255
+ uri = URI(jwks_endpoint.to_s)
1256
+ response = Net::HTTP.get_response(uri)
1257
+ return {} unless response.is_a?(Net::HTTPSuccess)
1258
+
1259
+ normalize_hash(JSON.parse(response.body))
1260
+ rescue
1261
+ {}
1262
+ end
1263
+
1264
+ def sso_decode_jwt_payload(token)
1265
+ payload = token.to_s.split(".")[1]
1266
+ return {} unless payload
1267
+
1268
+ JSON.parse(Base64.urlsafe_decode64(payload.ljust((payload.length + 3) & ~3, "=")))
1269
+ rescue
1270
+ {}
1271
+ end
1272
+
1273
+ def sso_append_error(url, error, description = nil)
1274
+ separator = url.to_s.include?("?") ? "&" : "?"
1275
+ query = {error: error, error_description: description}.compact
1276
+ "#{url}#{separator}#{URI.encode_www_form(query)}"
1277
+ end
1278
+
1279
+ def sso_default_provider(config, provider_id:, domain:)
1280
+ Array(config[:default_sso]).each do |raw_provider|
1281
+ default_provider = normalize_hash(raw_provider)
1282
+ next if !provider_id.empty? && default_provider[:provider_id].to_s != provider_id
1283
+ next if provider_id.empty? && default_provider[:domain].to_s.downcase != domain
1284
+
1285
+ oidc_config = default_provider[:oidc_config] ? sso_storage_config(default_provider[:oidc_config]) : nil
1286
+ saml_config = default_provider[:saml_config] ? sso_storage_config(default_provider[:saml_config]) : nil
1287
+ return {
1288
+ "issuer" => default_provider[:issuer] || default_provider.dig(:oidc_config, :issuer) || default_provider.dig(:saml_config, :issuer) || "",
1289
+ "providerId" => default_provider.fetch(:provider_id),
1290
+ "userId" => "default",
1291
+ "domain" => default_provider[:domain],
1292
+ "domainVerified" => true,
1293
+ "oidcConfig" => oidc_config,
1294
+ "samlConfig" => saml_config
1295
+ }.compact
1296
+ end
1297
+ nil
1298
+ end
1299
+
1300
+ def sso_oidc_pkce_state(provider)
1301
+ return {} unless normalize_hash(provider["oidcConfig"] || {})[:pkce]
1302
+
1303
+ {codeVerifier: SecureRandom.urlsafe_base64(48)}
1304
+ end
1305
+
1306
+ def sso_decode_state(state, secret)
1307
+ BetterAuth::Crypto.verify_jwt(state.to_s, secret)
1308
+ rescue
1309
+ nil
1310
+ end
1311
+
1312
+ def sso_base64_urlsafe(value)
1313
+ Base64.strict_encode64(value).tr("+/", "-_").delete("=")
1314
+ end
1315
+
1316
+ def sso_storage_config(config)
1317
+ normalize_hash(config || {}).each_with_object({}) do |(key, value), result|
1318
+ result[Schema.storage_key(key)] = value unless value.respond_to?(:call)
1319
+ end
1320
+ end
1321
+
1322
+ def sso_provider_limit(user, config)
1323
+ limit = config[:providers_limit]
1324
+ limit = 10 if limit.nil?
1325
+ limit.respond_to?(:call) ? limit.call(user) : limit
1326
+ end
1327
+
1328
+ def sso_validate_url!(value, message)
1329
+ uri = URI(value.to_s)
1330
+ unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
1331
+ raise APIError.new("BAD_REQUEST", message: message)
1332
+ end
1333
+ rescue URI::InvalidURIError
1334
+ raise APIError.new("BAD_REQUEST", message: message)
1335
+ end
1336
+
1337
+ def sso_validate_organization_membership!(ctx, user_id, organization_id)
1338
+ member = ctx.context.adapter.find_one(
1339
+ model: "member",
1340
+ where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
1341
+ )
1342
+ raise APIError.new("BAD_REQUEST", message: "You are not a member of the organization") unless member
1343
+ end
1344
+
1345
+ def sso_hydrate_oidc_config(issuer, oidc_config, ctx)
1346
+ existing = oidc_config.merge(issuer: issuer)
1347
+ discovered = sso_discover_oidc_config(
1348
+ issuer: issuer,
1349
+ existing_config: existing,
1350
+ fetch: ctx.context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:oidc_discovery_fetch, nil),
1351
+ trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) }
1352
+ )
1353
+ existing.merge(discovered)
1354
+ end
1355
+
1356
+ def sso_oidc_needs_runtime_discovery?(oidc_config)
1357
+ config = normalize_hash(oidc_config || {})
1358
+ config[:authorization_endpoint].to_s.empty? ||
1359
+ config[:token_endpoint].to_s.empty?
1360
+ end
1361
+
1362
+ def sso_ensure_runtime_oidc_provider(ctx, provider, plugin_config, require_jwks: false)
1363
+ oidc_config = normalize_hash(provider["oidcConfig"] || {})
1364
+ needs_discovery = sso_oidc_needs_runtime_discovery?(oidc_config) || (require_jwks && oidc_config[:jwks_endpoint].to_s.empty?)
1365
+ return provider if !needs_discovery
1366
+
1367
+ discovered = sso_discover_oidc_config(
1368
+ issuer: provider.fetch("issuer"),
1369
+ existing_config: oidc_config.merge(issuer: provider.fetch("issuer")),
1370
+ fetch: plugin_config[:oidc_discovery_fetch],
1371
+ trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) }
1372
+ )
1373
+ provider.merge("oidcConfig" => oidc_config.merge(discovered))
1374
+ end
1375
+
1376
+ def sso_oidc_redirect_uri(context, provider_id)
1377
+ redirect_uri = context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:redirect_uri, nil)
1378
+ if redirect_uri && !redirect_uri.to_s.strip.empty?
1379
+ value = redirect_uri.to_s
1380
+ return value if URI(value).absolute?
1381
+
1382
+ path = value.start_with?("/") ? value : "/#{value}"
1383
+ return "#{context.base_url}#{path}"
1384
+ end
1385
+
1386
+ "#{context.base_url}/sso/callback/#{provider_id}"
1387
+ rescue URI::InvalidURIError
1388
+ "#{context.base_url}/sso/callback/#{provider_id}"
1389
+ end
1390
+
550
1391
  def sso_email_domain_matches?(email_domain, provider_domain)
1392
+ email_domain = email_domain.to_s.strip.downcase
1393
+ email_domain = email_domain.split("@", 2).last if email_domain.include?("@")
1394
+ return false if email_domain.to_s.empty?
1395
+
551
1396
  provider_domain.to_s.split(",").map { |value| value.strip.downcase }.reject(&:empty?).any? do |domain|
552
1397
  email_domain == domain || email_domain.end_with?(".#{domain}")
553
1398
  end
@@ -563,7 +1408,7 @@ module BetterAuth
563
1408
  def sso_provider_access?(provider, user_id, ctx)
564
1409
  organization_id = provider["organizationId"]
565
1410
  return provider["userId"] == user_id if organization_id.to_s.empty?
566
- return false unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
1411
+ return provider["userId"] == user_id unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
567
1412
 
568
1413
  member = ctx.context.adapter.find_one(
569
1414
  model: "member",
@@ -572,6 +1417,53 @@ module BetterAuth
572
1417
  Array(member&.fetch("role", nil).to_s.split(",")).map(&:strip).any? { |role| %w[owner admin].include?(role) }
573
1418
  end
574
1419
 
1420
+ def sso_authorize_domain_verification!(ctx, provider, user_id)
1421
+ organization_id = provider["organizationId"]
1422
+ is_org_member = true
1423
+ if organization_id
1424
+ is_org_member = !!ctx.context.adapter.find_one(
1425
+ model: "member",
1426
+ where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
1427
+ )
1428
+ end
1429
+ return if provider["userId"] == user_id && is_org_member
1430
+
1431
+ raise APIError.new("FORBIDDEN", message: "User must be owner of or belong to the SSO provider organization", code: "INSUFICCIENT_ACCESS")
1432
+ end
1433
+
1434
+ def sso_domain_verification_identifier(config, provider_id)
1435
+ prefix = config.dig(:domain_verification, :token_prefix) || "better-auth-token"
1436
+ "_#{prefix}-#{provider_id}"
1437
+ end
1438
+
1439
+ def sso_future_time?(value)
1440
+ time = value.is_a?(Time) ? value : Time.parse(value.to_s)
1441
+ time > Time.now
1442
+ rescue
1443
+ false
1444
+ end
1445
+
1446
+ def sso_hostname_from_domain(domain)
1447
+ value = domain.to_s.strip
1448
+ return nil if value.empty?
1449
+
1450
+ uri = URI(value.include?("://") ? value : "https://#{value}")
1451
+ uri.host
1452
+ rescue URI::InvalidURIError
1453
+ nil
1454
+ end
1455
+
1456
+ def sso_resolve_txt_records(hostname, config)
1457
+ resolver = config.dig(:domain_verification, :dns_txt_resolver)
1458
+ return Array(resolver.call(hostname)) if resolver.respond_to?(:call)
1459
+
1460
+ Resolv::DNS.open do |dns|
1461
+ dns.getresources(hostname, Resolv::DNS::Resource::IN::TXT).map { |record| record.strings }
1462
+ end
1463
+ rescue
1464
+ []
1465
+ end
1466
+
575
1467
  def sso_sanitize_provider(provider, context)
576
1468
  data = provider.dup
577
1469
  oidc_config = normalize_hash(data["oidcConfig"] || {})
@@ -579,12 +1471,19 @@ module BetterAuth
579
1471
  data["type"] = saml_config.empty? ? "oidc" : "saml"
580
1472
  data["organizationId"] ||= nil
581
1473
  data["domainVerified"] = !!data["domainVerified"]
1474
+ data.delete("domainVerified") unless sso_context_domain_verification_enabled?(context)
582
1475
  data["oidcConfig"] = oidc_config.empty? ? nil : sso_sanitize_oidc_config(oidc_config)
583
1476
  data["samlConfig"] = saml_config.empty? ? nil : sso_sanitize_saml_config(saml_config)
584
1477
  data["spMetadataUrl"] = "#{context.base_url}/sso/saml2/sp/metadata?providerId=#{URI.encode_www_form_component(data.fetch("providerId"))}"
585
1478
  data.compact
586
1479
  end
587
1480
 
1481
+ def sso_context_domain_verification_enabled?(context)
1482
+ context.options.plugins.any? do |plugin|
1483
+ plugin.id == "sso" && plugin.options.dig(:domain_verification, :enabled)
1484
+ end
1485
+ end
1486
+
588
1487
  def sso_sanitize_config(config)
589
1488
  data = normalize_hash(config || {})
590
1489
  data.delete(:client_secret)
@@ -611,6 +1510,7 @@ module BetterAuth
611
1510
  "callbackUrl" => config[:callback_url],
612
1511
  "audience" => config[:audience],
613
1512
  "wantAssertionsSigned" => config[:want_assertions_signed],
1513
+ "authnRequestsSigned" => config[:authn_requests_signed],
614
1514
  "identifierFormat" => config[:identifier_format],
615
1515
  "signatureAlgorithm" => config[:signature_algorithm],
616
1516
  "digestAlgorithm" => config[:digest_algorithm],