better_auth 0.7.0 → 0.10.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -1
  3. data/README.md +6 -6
  4. data/lib/better_auth/adapters/memory.rb +131 -17
  5. data/lib/better_auth/adapters/mongodb.rb +3 -3
  6. data/lib/better_auth/adapters/sql.rb +139 -57
  7. data/lib/better_auth/auth.rb +68 -1
  8. data/lib/better_auth/configuration.rb +15 -10
  9. data/lib/better_auth/context.rb +1 -1
  10. data/lib/better_auth/cookies.rb +11 -3
  11. data/lib/better_auth/doctor.rb +97 -0
  12. data/lib/better_auth/endpoint.rb +88 -5
  13. data/lib/better_auth/env.rb +38 -0
  14. data/lib/better_auth/http_client.rb +46 -0
  15. data/lib/better_auth/migration_plan.rb +15 -0
  16. data/lib/better_auth/oauth2.rb +1 -1
  17. data/lib/better_auth/plugins/admin.rb +6 -1
  18. data/lib/better_auth/plugins/anonymous.rb +2 -0
  19. data/lib/better_auth/plugins/captcha.rb +1 -1
  20. data/lib/better_auth/plugins/device_authorization.rb +34 -0
  21. data/lib/better_auth/plugins/dub.rb +8 -0
  22. data/lib/better_auth/plugins/generic_oauth.rb +34 -7
  23. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  24. data/lib/better_auth/plugins/jwt.rb +10 -3
  25. data/lib/better_auth/plugins/mcp/schema.rb +13 -13
  26. data/lib/better_auth/plugins/mcp.rb +41 -0
  27. data/lib/better_auth/plugins/oauth_protocol.rb +98 -21
  28. data/lib/better_auth/plugins/oidc_provider.rb +62 -3
  29. data/lib/better_auth/plugins/one_tap.rb +17 -5
  30. data/lib/better_auth/plugins/open_api.rb +42 -2
  31. data/lib/better_auth/plugins/organization.rb +122 -11
  32. data/lib/better_auth/plugins/phone_number.rb +1 -1
  33. data/lib/better_auth/plugins/two_factor.rb +21 -0
  34. data/lib/better_auth/rate_limiter.rb +7 -2
  35. data/lib/better_auth/routes/account.rb +4 -0
  36. data/lib/better_auth/routes/email_verification.rb +5 -1
  37. data/lib/better_auth/routes/password.rb +1 -0
  38. data/lib/better_auth/routes/social.rb +29 -1
  39. data/lib/better_auth/routes/user.rb +6 -2
  40. data/lib/better_auth/schema/sql.rb +104 -15
  41. data/lib/better_auth/schema.rb +35 -2
  42. data/lib/better_auth/session.rb +2 -1
  43. data/lib/better_auth/social_providers/base.rb +4 -9
  44. data/lib/better_auth/social_providers/facebook.rb +1 -1
  45. data/lib/better_auth/social_providers/github.rb +2 -0
  46. data/lib/better_auth/social_providers/line.rb +1 -1
  47. data/lib/better_auth/social_providers/paypal.rb +1 -1
  48. data/lib/better_auth/sql_migration.rb +566 -0
  49. data/lib/better_auth/url_helpers.rb +1 -1
  50. data/lib/better_auth/version.rb +1 -1
  51. data/lib/better_auth.rb +4 -0
  52. metadata +15 -8
@@ -2,7 +2,23 @@
2
2
 
3
3
  module BetterAuth
4
4
  class Auth
5
- attr_reader :handler, :api, :options, :context, :error_codes
5
+ TELEMETRY_ADAPTER_IDS = {
6
+ "BetterAuth::Adapters::Memory" => "memory",
7
+ "BetterAuth::Adapters::Postgres" => "postgres",
8
+ "BetterAuth::Adapters::MySQL" => "mysql",
9
+ "BetterAuth::Adapters::SQLite" => "sqlite",
10
+ "BetterAuth::Adapters::MSSQL" => "mssql"
11
+ }.freeze
12
+
13
+ TELEMETRY_DATABASE_IDS = {
14
+ memory: "memory",
15
+ postgres: "postgres",
16
+ mysql: "mysql",
17
+ sqlite: "sqlite",
18
+ mssql: "mssql"
19
+ }.freeze
20
+
21
+ attr_reader :handler, :api, :options, :context, :error_codes, :telemetry
6
22
 
7
23
  def initialize(options = {})
8
24
  @options = Configuration.new(options)
@@ -16,6 +32,7 @@ module BetterAuth
16
32
  Router.check_endpoint_conflicts(@options, @options.logger)
17
33
  @api = API.new(@context, @endpoints)
18
34
  @handler = Router.new(@context, @endpoints)
35
+ @telemetry = build_telemetry_publisher
19
36
  end
20
37
 
21
38
  def call(env)
@@ -38,5 +55,55 @@ module BetterAuth
38
55
  def build_endpoints
39
56
  Core.base_endpoints.merge(@plugin_registry.endpoints)
40
57
  end
58
+
59
+ def build_telemetry_publisher
60
+ require "better_auth/telemetry"
61
+ BetterAuth::Telemetry.create(@options, telemetry_context)
62
+ rescue LoadError
63
+ noop_telemetry_publisher
64
+ rescue => e
65
+ log_telemetry_error(e)
66
+ noop_telemetry_publisher
67
+ end
68
+
69
+ def telemetry_context
70
+ {
71
+ database: telemetry_database_id,
72
+ adapter: telemetry_adapter_id,
73
+ custom_track: nil,
74
+ skip_test_check: false
75
+ }
76
+ end
77
+
78
+ def telemetry_database_id
79
+ configured = @options.database
80
+ return "memory" if configured.nil?
81
+ return TELEMETRY_DATABASE_IDS[configured] if configured.is_a?(Symbol) && TELEMETRY_DATABASE_IDS.key?(configured)
82
+ return "adapter" if configured.respond_to?(:call)
83
+
84
+ TELEMETRY_ADAPTER_IDS[configured.class.name] || "adapter"
85
+ end
86
+
87
+ def telemetry_adapter_id
88
+ TELEMETRY_ADAPTER_IDS[@context.adapter.class.name] || @context.adapter.class.name
89
+ end
90
+
91
+ def noop_telemetry_publisher
92
+ Class.new do
93
+ def publish(_event) = nil
94
+
95
+ def enabled? = false
96
+ end.new
97
+ end
98
+
99
+ def log_telemetry_error(error)
100
+ logger = @options.logger
101
+ message = "[better-auth] telemetry creation failed: #{error.class}: #{error.message}"
102
+ if logger.respond_to?(:error)
103
+ logger.error(message)
104
+ else
105
+ Kernel.warn(message)
106
+ end
107
+ end
41
108
  end
42
109
  end
@@ -56,6 +56,7 @@ module BetterAuth
56
56
  :on_api_error,
57
57
  :disabled_paths,
58
58
  :trusted_origins_callback,
59
+ :telemetry,
59
60
  :logger
60
61
 
61
62
  def initialize(options = {})
@@ -73,12 +74,13 @@ module BetterAuth
73
74
  @database_hooks = options[:database_hooks]
74
75
  @hooks = options[:hooks]
75
76
  @on_api_error = symbolize_keys(options[:on_api_error] || options[:on_apierror] || {})
76
- @social_providers = symbolize_keys(options[:social_providers] || {})
77
+ @telemetry = symbolize_keys(options[:telemetry] || {})
78
+ @social_providers = normalize_social_providers(options[:social_providers])
77
79
  @trusted_origins_callbacks = []
78
80
  @trusted_origins_callbacks << options[:trusted_origins] if options[:trusted_origins].respond_to?(:call)
79
81
  @trusted_origins_callback = combined_trusted_origins_callback
80
82
  legacy_secret = resolve_secret(options, allow_test_default: false)
81
- secrets = options.key?(:secrets) ? options[:secrets] : SecretConfig.parse_env(ENV["BETTER_AUTH_SECRETS"])
83
+ secrets = options.key?(:secrets) ? options[:secrets] : SecretConfig.parse_env(Env.get("BETTER_AUTH_SECRETS"))
82
84
  if secrets
83
85
  @secret_config = SecretConfig.build(secrets, legacy_secret, logger: logger)
84
86
  @secret = @secret_config.current_secret
@@ -153,7 +155,8 @@ module BetterAuth
153
155
  database_hooks: database_hooks,
154
156
  hooks: hooks,
155
157
  on_api_error: on_api_error,
156
- disabled_paths: disabled_paths
158
+ disabled_paths: disabled_paths,
159
+ telemetry: telemetry
157
160
  }
158
161
  end
159
162
 
@@ -279,17 +282,13 @@ module BetterAuth
279
282
  def env_base_url
280
283
  base_url = ENV["BASE_URL"]
281
284
  [
282
- ENV["BETTER_AUTH_URL"],
283
- ENV["NEXT_PUBLIC_BETTER_AUTH_URL"],
284
- ENV["PUBLIC_BETTER_AUTH_URL"],
285
- ENV["NUXT_PUBLIC_BETTER_AUTH_URL"],
286
- ENV["NUXT_PUBLIC_AUTH_URL"],
285
+ Env.get("BETTER_AUTH_URL"),
287
286
  (base_url unless base_url == "/")
288
287
  ].find { |value| value && !value.empty? }
289
288
  end
290
289
 
291
290
  def resolve_secret(options, allow_test_default: true)
292
- [options[:secret], ENV["BETTER_AUTH_SECRET"], ENV["AUTH_SECRET"]].find { |value| value && !value.empty? } ||
291
+ [options[:secret], Env.get("BETTER_AUTH_SECRET"), ENV["AUTH_SECRET"]].find { |value| value && !value.empty? } ||
293
292
  ((allow_test_default && test_environment?) ? DEFAULT_SECRET : nil)
294
293
  end
295
294
 
@@ -372,6 +371,12 @@ module BetterAuth
372
371
  Array(value).compact.reject { |plugin| plugin == false }.map { |plugin| Plugin.coerce(plugin) }
373
372
  end
374
373
 
374
+ def normalize_social_providers(value)
375
+ symbolize_keys(value || {}).reject do |_id, provider|
376
+ provider.nil? || provider == false || (provider.is_a?(Hash) && provider[:enabled] == false)
377
+ end
378
+ end
379
+
375
380
  def normalize_trusted_origins(value)
376
381
  origins = []
377
382
  origins << base_url unless base_url.nil? || base_url.empty?
@@ -408,7 +413,7 @@ module BetterAuth
408
413
  end
409
414
 
410
415
  def env_trusted_origins
411
- ENV.fetch("BETTER_AUTH_TRUSTED_ORIGINS", "").split(",").map(&:strip).reject(&:empty?)
416
+ Env.csv("BETTER_AUTH_TRUSTED_ORIGINS")
412
417
  end
413
418
 
414
419
  def symbolize_keys(value)
@@ -321,7 +321,7 @@ module BetterAuth
321
321
  if options.trusted_origins_callback
322
322
  origins.concat(Array(options.trusted_origins_callback.call(request)).compact)
323
323
  end
324
- origins.concat(ENV.fetch("BETTER_AUTH_TRUSTED_ORIGINS", "").split(",").map(&:strip))
324
+ origins.concat(Env.csv("BETTER_AUTH_TRUSTED_ORIGINS"))
325
325
  origins.map(&:to_s).reject(&:empty?).uniq
326
326
  rescue URI::InvalidURIError
327
327
  options.trusted_origins
@@ -67,7 +67,7 @@ module BetterAuth
67
67
  name, value = pair.split("=", 2)
68
68
  next if name.to_s.empty? || value.nil?
69
69
 
70
- result[name.strip] = value.strip
70
+ result[name.strip] = decode_cookie_value(value.strip)
71
71
  end
72
72
  end
73
73
 
@@ -150,12 +150,14 @@ module BetterAuth
150
150
  Crypto.symmetric_decode_jwt(value, ctx.context.secret_config, "better-auth-account")
151
151
  end
152
152
 
153
- def get_cookie_cache(request_or_cookie_header, secret:, strategy: "compact", version: nil, cookie_prefix: "better-auth", cookie_name: "session_data", is_secure: nil)
153
+ def get_cookie_cache(request_or_cookie_header, secret:, strategy: "compact", version: nil, cookie_prefix: "better-auth", cookie_name: "session_data", is_secure: nil, cookie_full_name: nil)
154
154
  cookie_header = header_value(request_or_cookie_header)
155
155
  return nil if cookie_header.to_s.empty?
156
156
 
157
157
  parsed = parse_cookies(cookie_header)
158
- name = if is_secure.nil?
158
+ name = if cookie_full_name
159
+ cookie_full_name
160
+ elsif is_secure.nil?
159
161
  production_environment? ? "#{SECURE_COOKIE_PREFIX}#{cookie_prefix}.#{cookie_name}" : "#{cookie_prefix}.#{cookie_name}"
160
162
  else
161
163
  secure_prefix = is_secure ? SECURE_COOKIE_PREFIX : ""
@@ -248,6 +250,12 @@ module BetterAuth
248
250
  chunks.sort_by(&:first).map(&:last).join
249
251
  end
250
252
 
253
+ def decode_cookie_value(value)
254
+ URI.decode_uri_component(value)
255
+ rescue ArgumentError
256
+ value
257
+ end
258
+
251
259
  def header_value(request_or_cookie_header)
252
260
  return request_or_cookie_header.headers["cookie"] if request_or_cookie_header.respond_to?(:headers)
253
261
  return request_or_cookie_header.get_header("HTTP_COOKIE") if request_or_cookie_header.respond_to?(:get_header)
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth/sql_migration"
4
+
5
+ module BetterAuth
6
+ module Doctor
7
+ Result = Struct.new(:ok, :warnings, :errors, keyword_init: true) do
8
+ def success?
9
+ errors.empty?
10
+ end
11
+ end
12
+
13
+ module_function
14
+
15
+ def check(config_or_options)
16
+ config = BetterAuth::SQLMigration.configuration_for(config_or_options)
17
+ result = Result.new(ok: ["config loaded"], warnings: [], errors: [])
18
+
19
+ check_secret(config, result)
20
+ check_base_url(config, result)
21
+ check_rate_limit(config, result)
22
+ check_database(config, result)
23
+
24
+ result
25
+ end
26
+
27
+ def print(result, stdout:, stderr:)
28
+ result.ok.each { |message| stdout.puts "OK #{message}" }
29
+ result.warnings.each { |message| stdout.puts "WARN #{message}" }
30
+ result.errors.each { |message| stderr.puts "ERROR #{message}" }
31
+ result.success? ? 0 : 1
32
+ end
33
+
34
+ def check_secret(config, result)
35
+ secret = config.secret.to_s
36
+ if secret.empty?
37
+ result.errors << "secret is missing"
38
+ elsif secret == BetterAuth::Configuration::DEFAULT_SECRET
39
+ result.errors << "secret uses the default development value"
40
+ elsif secret.length < 32
41
+ result.errors << "secret should be at least 32 characters"
42
+ elsif entropy(secret) < 120
43
+ result.errors << "secret appears low-entropy; use a random production secret"
44
+ else
45
+ result.ok << "secret length and entropy look acceptable"
46
+ end
47
+ end
48
+
49
+ def check_base_url(config, result)
50
+ base_url = config.base_url.to_s
51
+ if base_url.empty?
52
+ result.warnings << "base_url is not configured; set it explicitly in production"
53
+ elsif !base_url.start_with?("https://")
54
+ result.warnings << "base_url is not HTTPS"
55
+ else
56
+ result.ok << "base_url uses HTTPS"
57
+ end
58
+ end
59
+
60
+ def check_rate_limit(config, result)
61
+ rate_limit = config.rate_limit || {}
62
+ result.warnings << "rate_limit is disabled" unless rate_limit[:enabled]
63
+ if rate_limit[:storage].to_s == "memory"
64
+ result.warnings << "rate_limit uses memory storage; use database or secondary-storage for multi-process production deployments"
65
+ else
66
+ result.ok << "rate_limit storage is #{rate_limit[:storage]}"
67
+ end
68
+ end
69
+
70
+ def check_database(config, result)
71
+ auth = BetterAuth.auth(config.to_h)
72
+ adapter = auth.context.adapter
73
+ unless adapter.respond_to?(:dialect) && adapter.respond_to?(:connection)
74
+ result.warnings << "database adapter does not expose SQL migration introspection; schema drift check skipped"
75
+ return
76
+ end
77
+
78
+ result.ok << "database adapter supports SQL migrations"
79
+ plan = BetterAuth::SQLMigration.plan(config, connection: adapter.connection, dialect: adapter.dialect)
80
+ if plan.empty?
81
+ result.ok << "database schema is up to date"
82
+ else
83
+ result.warnings << "database has pending Better Auth migrations"
84
+ plan.warnings.each { |warning| result.warnings << warning }
85
+ end
86
+ rescue BetterAuth::SQLMigration::UnsupportedAdapterError => error
87
+ result.warnings << error.message
88
+ end
89
+
90
+ def entropy(value)
91
+ unique = value.chars.uniq.length
92
+ return 0 if unique.zero?
93
+
94
+ Math.log2(unique**value.length)
95
+ end
96
+ end
97
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "uri"
4
5
 
5
6
  module BetterAuth
6
7
  class Endpoint
@@ -24,6 +25,7 @@ module BetterAuth
24
25
  @headers_schema = headers_schema
25
26
  @metadata = metadata || {}
26
27
  apply_default_open_api_metadata!
28
+ apply_open_api_defaults!
27
29
  apply_open_api_schemas!
28
30
  @options = endpoint_options
29
31
  @use = Array(use)
@@ -40,13 +42,12 @@ module BetterAuth
40
42
  end
41
43
 
42
44
  def call(context)
43
- apply_schemas!(context)
44
-
45
45
  use.each do |middleware|
46
46
  middleware_result = middleware.call(context)
47
47
  return Result.from_value(middleware_result, context) if middleware_result
48
48
  end
49
49
 
50
+ apply_schemas!(context)
50
51
  Result.from_value(handler.call(context), context)
51
52
  end
52
53
 
@@ -72,6 +73,41 @@ module BetterAuth
72
73
  metadata[:openapi] = BetterAuth::OpenAPI.default_metadata(path, methods)
73
74
  end
74
75
 
76
+ def apply_open_api_defaults!
77
+ return unless path
78
+ return unless defined?(BetterAuth::OpenAPI)
79
+
80
+ openapi = fetch_key(metadata, :openapi)
81
+ return unless openapi.is_a?(Hash)
82
+
83
+ defaults = BetterAuth::OpenAPI.default_metadata(path, methods)
84
+ openapi[:operationId] = defaults[:operationId] if fetch_key(openapi, :operationId).to_s.empty?
85
+ openapi[:description] = defaults[:description] if default_open_api_description?(fetch_key(openapi, :description))
86
+ openapi[:parameters] = merge_open_api_parameters(defaults[:parameters], fetch_key(openapi, :parameters))
87
+ openapi[:responses] = defaults[:responses].merge(fetch_key(openapi, :responses) || {})
88
+ if request_body_method? && !fetch_key(openapi, :requestBody).is_a?(Hash)
89
+ openapi[:requestBody] = defaults[:requestBody] || BetterAuth::OpenAPI.default_request_body
90
+ end
91
+ end
92
+
93
+ def default_open_api_description?(description)
94
+ methods.any? { |method| description.to_s == "#{method} #{path}" }
95
+ end
96
+
97
+ def request_body_method?
98
+ methods.any? { |method| %w[POST PUT PATCH].include?(method) }
99
+ end
100
+
101
+ def merge_open_api_parameters(default_parameters, custom_parameters)
102
+ merged = Array(custom_parameters).dup
103
+ Array(default_parameters).each do |parameter|
104
+ next if merged.any? { |entry| fetch_key(entry, :name).to_s == fetch_key(parameter, :name).to_s && fetch_key(entry, :in).to_s == fetch_key(parameter, :in).to_s }
105
+
106
+ merged << parameter
107
+ end
108
+ merged
109
+ end
110
+
75
111
  def apply_open_api_schemas!
76
112
  openapi = fetch_key(metadata, :openapi)
77
113
  return unless openapi.is_a?(Hash)
@@ -172,6 +208,42 @@ module BetterAuth
172
208
  class Result
173
209
  attr_accessor :response, :status, :headers
174
210
 
211
+ class SetCookieHeader < Array
212
+ def self.from(value)
213
+ new(value.lines.map(&:chomp))
214
+ end
215
+
216
+ def include?(value)
217
+ return super unless value.is_a?(String)
218
+
219
+ any? { |line| line.include?(value) }
220
+ end
221
+
222
+ def lines
223
+ self
224
+ end
225
+
226
+ def match(...)
227
+ to_s.match(...)
228
+ end
229
+
230
+ def =~(pattern)
231
+ to_s =~ pattern
232
+ end
233
+
234
+ def split(...)
235
+ to_s.split(...)
236
+ end
237
+
238
+ def to_s
239
+ join("\n")
240
+ end
241
+
242
+ def to_str
243
+ to_s
244
+ end
245
+ end
246
+
175
247
  def initialize(response:, status: 200, headers: {}, raw_response: nil)
176
248
  @response = response
177
249
  @status = status
@@ -223,7 +295,7 @@ module BetterAuth
223
295
  end
224
296
 
225
297
  def to_response
226
- return Response.from_rack(@raw_response) if raw_response?
298
+ return Response.from_rack([@raw_response[0], rack_headers(@raw_response[1]), @raw_response[2]]) if raw_response?
227
299
 
228
300
  body = if response.nil?
229
301
  [JSON.generate(nil)]
@@ -232,12 +304,23 @@ module BetterAuth
232
304
  else
233
305
  [JSON.generate(response)]
234
306
  end
235
- response_headers = {"content-type" => "application/json"}.merge(headers)
307
+ response_headers = rack_headers({"content-type" => "application/json"}.merge(headers))
236
308
  Response.new(status: status, headers: response_headers, body: body)
237
309
  end
238
310
 
239
311
  private
240
312
 
313
+ def rack_headers(value)
314
+ value.each_with_object({}) do |(key, header_value), result|
315
+ normalized = key.to_s.downcase
316
+ result[normalized] = if normalized == "set-cookie" && header_value.is_a?(String) && header_value.include?("\n")
317
+ SetCookieHeader.from(header_value)
318
+ else
319
+ header_value
320
+ end
321
+ end
322
+ end
323
+
241
324
  def normalize_headers(headers)
242
325
  headers.each_with_object({}) do |(key, value), result|
243
326
  result[key.to_s.downcase] = value
@@ -290,7 +373,7 @@ module BetterAuth
290
373
 
291
374
  def set_cookie(name, value, options = {})
292
375
  attributes = cookie_attributes(options)
293
- cookie = (["#{name}=#{value}"] + attributes).join("; ")
376
+ cookie = (["#{name}=#{URI.encode_uri_component(value.to_s)}"] + attributes).join("; ")
294
377
  set_header("set-cookie", cookie)
295
378
  end
296
379
 
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Env
5
+ module_function
6
+
7
+ def get(name)
8
+ open_auth_name = open_auth_name(name)
9
+ return nil unless open_auth_name
10
+
11
+ value = ENV[open_auth_name]
12
+ return value if present?(value)
13
+
14
+ value = ENV[name]
15
+ present?(value) ? value : nil
16
+ end
17
+
18
+ def fetch(name, default = nil)
19
+ value = get(name)
20
+ value.nil? ? default : value
21
+ end
22
+
23
+ def csv(name)
24
+ fetch(name, "").split(",").map(&:strip).reject(&:empty?)
25
+ end
26
+
27
+ def open_auth_name(name)
28
+ text = name.to_s
29
+ return nil unless text.start_with?("BETTER_AUTH_")
30
+
31
+ text.sub(/\ABETTER_AUTH_/, "OPEN_AUTH_")
32
+ end
33
+
34
+ def present?(value)
35
+ value && !value.empty?
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module BetterAuth
8
+ module HTTPClient
9
+ DEFAULT_OPEN_TIMEOUT = 5
10
+ DEFAULT_READ_TIMEOUT = 5
11
+
12
+ module_function
13
+
14
+ def request(uri, request, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
15
+ Net::HTTP.start(
16
+ uri.hostname || uri.host,
17
+ uri.port,
18
+ use_ssl: uri.scheme == "https",
19
+ open_timeout: open_timeout,
20
+ read_timeout: read_timeout
21
+ ) do |http|
22
+ http.request(request)
23
+ end
24
+ end
25
+
26
+ def get_response(uri, headers = {})
27
+ request = Net::HTTP::Get.new(uri)
28
+ headers.each { |key, value| request[key.to_s] = value.to_s }
29
+ request(uri, request)
30
+ end
31
+
32
+ def post_form(uri, form_body, headers = {})
33
+ request = Net::HTTP::Post.new(uri)
34
+ headers.each { |key, value| request[key.to_s] = value.to_s }
35
+ request["Content-Type"] ||= "application/x-www-form-urlencoded"
36
+ request.body = form_body
37
+ request(uri, request)
38
+ end
39
+
40
+ def get_json(url, headers = {})
41
+ uri = url.is_a?(URI) ? url : URI.parse(url.to_s)
42
+ response = get_response(uri, headers)
43
+ response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body.to_s) : nil
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module MigrationPlan
5
+ TableChange = Struct.new(:logical_name, :table_name, :table, :order, keyword_init: true)
6
+ FieldChange = Struct.new(:logical_name, :table_name, :fields, :table, :order, keyword_init: true)
7
+ IndexChange = Struct.new(:table_name, :field_name, :name, :unique, :field, keyword_init: true)
8
+
9
+ Plan = Struct.new(:to_create, :to_add, :to_index, :warnings, :dialect, :tables, keyword_init: true) do
10
+ def empty?
11
+ to_create.empty? && to_add.empty? && to_index.empty?
12
+ end
13
+ end
14
+ end
15
+ end
@@ -81,7 +81,7 @@ module BetterAuth
81
81
 
82
82
  def post_form(token_endpoint, request)
83
83
  uri = URI.parse(token_endpoint)
84
- response = Net::HTTP.post(uri, URI.encode_www_form(request[:body]), request[:headers])
84
+ response = HTTPClient.post_form(uri, URI.encode_www_form(request[:body]), request[:headers])
85
85
  JSON.parse(response.body)
86
86
  end
87
87
 
@@ -66,7 +66,11 @@ module BetterAuth
66
66
  after: [
67
67
  {
68
68
  matcher: ->(ctx) { ctx.path == "/list-sessions" },
69
- handler: ->(ctx) { ctx.json(Array(ctx.returned).reject { |session| session["impersonatedBy"] || session[:impersonatedBy] }) }
69
+ handler: lambda do |ctx|
70
+ next unless ctx.returned.is_a?(Array)
71
+
72
+ ctx.json(ctx.returned.reject { |session| session["impersonatedBy"] || session[:impersonatedBy] })
73
+ end
70
74
  }
71
75
  ]
72
76
  },
@@ -436,6 +440,7 @@ module BetterAuth
436
440
  openapi: {
437
441
  operationId: "stopImpersonating",
438
442
  description: "Stop impersonating a user",
443
+ requestBody: OpenAPI.empty_request_body,
439
444
  responses: {
440
445
  "200" => OpenAPI.json_response("Impersonation stopped", OpenAPI.session_response_schema_pair)
441
446
  }
@@ -47,6 +47,7 @@ module BetterAuth
47
47
  openapi: {
48
48
  operationId: "signInAnonymous",
49
49
  description: "Sign in anonymously",
50
+ requestBody: OpenAPI.empty_request_body,
50
51
  responses: {
51
52
  "200" => OpenAPI.json_response(
52
53
  "Anonymous session created",
@@ -96,6 +97,7 @@ module BetterAuth
96
97
  openapi: {
97
98
  operationId: "deleteAnonymousUser",
98
99
  description: "Delete the current anonymous user",
100
+ requestBody: OpenAPI.empty_request_body,
99
101
  responses: {
100
102
  "200" => OpenAPI.json_response("Anonymous user deleted", OpenAPI.success_response_schema)
101
103
  }
@@ -103,7 +103,7 @@ module BetterAuth
103
103
  else
104
104
  URI.encode_www_form(verifier[:payload])
105
105
  end
106
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
106
+ response = HTTPClient.request(uri, request)
107
107
  raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"] unless response.is_a?(Net::HTTPSuccess)
108
108
 
109
109
  JSON.parse(response.body.to_s)
@@ -54,6 +54,14 @@ module BetterAuth
54
54
  openapi: {
55
55
  operationId: "requestDeviceCode",
56
56
  description: "Request a device and user code",
57
+ requestBody: OpenAPI.json_request_body(
58
+ OpenAPI.object_schema(
59
+ {
60
+ client_id: {type: "string", description: "OAuth client ID"},
61
+ scope: {type: "string", description: "Requested scopes"}
62
+ }
63
+ )
64
+ ),
57
65
  responses: {
58
66
  "200" => OpenAPI.json_response("Success", device_code_response_schema)
59
67
  }
@@ -106,6 +114,16 @@ module BetterAuth
106
114
  openapi: {
107
115
  operationId: "exchangeDeviceToken",
108
116
  description: "Exchange device code for access token",
117
+ requestBody: OpenAPI.json_request_body(
118
+ OpenAPI.object_schema(
119
+ {
120
+ grant_type: {type: "string", enum: [OAuthProtocol::DEVICE_CODE_GRANT]},
121
+ device_code: {type: "string"},
122
+ client_id: {type: "string"}
123
+ },
124
+ required: ["grant_type", "device_code"]
125
+ )
126
+ ),
109
127
  responses: {
110
128
  "200" => OpenAPI.json_response("Success", device_token_response_schema)
111
129
  }
@@ -197,6 +215,14 @@ module BetterAuth
197
215
  openapi: {
198
216
  operationId: "approveDevice",
199
217
  description: "Approve a device authorization request",
218
+ requestBody: OpenAPI.json_request_body(
219
+ OpenAPI.object_schema(
220
+ {
221
+ user_code: {type: "string", description: "User code shown on the device"},
222
+ userCode: {type: "string", description: "User code shown on the device"}
223
+ }
224
+ )
225
+ ),
200
226
  responses: {
201
227
  "200" => OpenAPI.json_response("Success", OpenAPI.success_response_schema)
202
228
  }
@@ -218,6 +244,14 @@ module BetterAuth
218
244
  openapi: {
219
245
  operationId: "denyDevice",
220
246
  description: "Deny a device authorization request",
247
+ requestBody: OpenAPI.json_request_body(
248
+ OpenAPI.object_schema(
249
+ {
250
+ user_code: {type: "string", description: "User code shown on the device"},
251
+ userCode: {type: "string", description: "User code shown on the device"}
252
+ }
253
+ )
254
+ ),
221
255
  responses: {
222
256
  "200" => OpenAPI.json_response("Success", OpenAPI.success_response_schema)
223
257
  }