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
@@ -12,6 +12,31 @@ module BetterAuth
12
12
  statements.concat(tables.flat_map { |_logical_name, table| index_statements(table, dialect) })
13
13
  end
14
14
 
15
+ def pending_statements(plan)
16
+ statements = plan.to_create.map do |change|
17
+ create_table_statement(change.logical_name, change.table, plan.dialect, plan.tables)
18
+ end
19
+ statements.concat(plan.to_add.flat_map do |change|
20
+ change.fields.map do |logical_field, attributes|
21
+ if logical_field.to_s == "id" && plan.dialect == :postgres
22
+ add_postgres_id_column_statements(change.table_name)
23
+ else
24
+ add_column_statement(change.table_name, logical_field, attributes, plan.dialect)
25
+ end
26
+ end
27
+ end.flatten)
28
+ statements.concat(plan.to_index.map do |change|
29
+ index_statement(
30
+ change.table_name,
31
+ change.field_name,
32
+ change.name,
33
+ plan.dialect,
34
+ unique: change.unique,
35
+ where_not_null: filtered_unique_index?(change.field, plan.dialect)
36
+ )
37
+ end)
38
+ end
39
+
15
40
  def create_table_statement(logical_name, table, dialect, tables = nil)
16
41
  table_name = table.fetch(:model_name)
17
42
  columns = table.fetch(:fields).map do |logical_field, attributes|
@@ -28,7 +53,7 @@ module BetterAuth
28
53
  when :mysql
29
54
  %(CREATE TABLE IF NOT EXISTS #{quote(table_name, dialect)} (\n #{body}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;)
30
55
  when :mssql
31
- %(IF OBJECT_ID(N'#{quote(table_name, dialect)}', N'U') IS NULL\nCREATE TABLE #{quote(table_name, dialect)} (\n #{body}\n);)
56
+ %(#{mssql_required_set_options}\nIF OBJECT_ID(N'#{quote(table_name, dialect)}', N'U') IS NULL\nCREATE TABLE #{quote(table_name, dialect)} (\n #{body}\n);)
32
57
  else
33
58
  raise ArgumentError, "Unsupported SQL dialect: #{dialect}"
34
59
  end
@@ -52,7 +77,7 @@ module BetterAuth
52
77
  constraints = []
53
78
  column = attributes[:field_name] || physical_name(logical_field)
54
79
 
55
- if attributes[:unique] && logical_field != "id"
80
+ if attributes[:unique] && logical_field != "id" && !(dialect == :mssql && !attributes[:required])
56
81
  constraints << unique_constraint(table_name, column, dialect)
57
82
  end
58
83
 
@@ -67,21 +92,62 @@ module BetterAuth
67
92
  def index_statements(table, dialect)
68
93
  table_name = table.fetch(:model_name)
69
94
  table.fetch(:fields).filter_map do |logical_field, attributes|
70
- next unless attributes[:index]
95
+ nullable_unique_mssql = dialect == :mssql && attributes[:unique] && logical_field != "id" && !attributes[:required]
96
+ next if attributes[:unique] && !nullable_unique_mssql
97
+ next unless attributes[:index] || nullable_unique_mssql
71
98
 
72
99
  column = attributes[:field_name] || Schema.physical_name(logical_field)
73
- name = "index_#{table_name}_on_#{column}"
74
- case dialect
75
- when :postgres, :sqlite
76
- %(CREATE INDEX IF NOT EXISTS #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
77
- when :mysql
78
- %(CREATE INDEX #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
79
- when :mssql
80
- %(IF NOT EXISTS (SELECT name FROM sys.indexes WHERE name = '#{name.gsub("'", "''")}' AND object_id = OBJECT_ID(N'#{quote(table_name, dialect)}')) CREATE INDEX #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
81
- end
100
+ unique = attributes[:unique] && dialect == :mssql
101
+ name = unique ? "uniq_#{table_name}_#{column}" : "index_#{table_name}_on_#{column}"
102
+ index_statement(table_name, column, name, dialect, unique: unique, where_not_null: filtered_unique_index?(attributes, dialect))
103
+ end
104
+ end
105
+
106
+ def add_column_statement(table_name, logical_field, attributes, dialect)
107
+ keyword = (dialect == :mssql) ? "ADD" : "ADD COLUMN"
108
+ %(ALTER TABLE #{quote(table_name, dialect)} #{keyword} #{column_definition(table_name, logical_field, attributes, dialect)};)
109
+ end
110
+
111
+ def add_postgres_id_column_statements(table_name)
112
+ quoted_table = quote(table_name, :postgres)
113
+ quoted_id = quote("id", :postgres)
114
+ [
115
+ %(ALTER TABLE #{quoted_table} ADD COLUMN #{quoted_id} text;),
116
+ %(UPDATE #{quoted_table} SET #{quoted_id} = md5(random()::text || clock_timestamp()::text || ctid::text) WHERE #{quoted_id} IS NULL;),
117
+ %(ALTER TABLE #{quoted_table} ALTER COLUMN #{quoted_id} SET NOT NULL;),
118
+ %(ALTER TABLE #{quoted_table} ADD PRIMARY KEY (#{quoted_id});)
119
+ ]
120
+ end
121
+
122
+ def index_statement(table_name, column, name, dialect, unique: false, where_not_null: false)
123
+ unique_prefix = unique ? "UNIQUE " : ""
124
+ case dialect
125
+ when :postgres, :sqlite
126
+ %(CREATE #{unique_prefix}INDEX IF NOT EXISTS #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
127
+ when :mysql
128
+ %(CREATE #{unique_prefix}INDEX #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
129
+ when :mssql
130
+ filter = where_not_null ? " WHERE #{quote(column, dialect)} IS NOT NULL" : ""
131
+ %(#{mssql_required_set_options}\nIF NOT EXISTS (SELECT name FROM sys.indexes WHERE name = '#{name.gsub("'", "''")}' AND object_id = OBJECT_ID(N'#{quote(table_name, dialect)}')) CREATE #{unique_prefix}INDEX #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)})#{filter};)
82
132
  end
83
133
  end
84
134
 
135
+ def filtered_unique_index?(attributes, dialect)
136
+ dialect == :mssql && attributes[:unique] && !attributes[:required]
137
+ end
138
+
139
+ def mssql_required_set_options
140
+ <<~SQL.strip
141
+ SET ANSI_NULLS ON;
142
+ SET QUOTED_IDENTIFIER ON;
143
+ SET ANSI_WARNINGS ON;
144
+ SET ANSI_PADDING ON;
145
+ SET CONCAT_NULL_YIELDS_NULL ON;
146
+ SET ARITHABORT ON;
147
+ SET NUMERIC_ROUNDABORT OFF;
148
+ SQL
149
+ end
150
+
85
151
  def sql_type(logical_field, attributes, dialect)
86
152
  case attributes[:type]
87
153
  when "boolean"
@@ -121,7 +187,7 @@ module BetterAuth
121
187
  end
122
188
  else
123
189
  if dialect == :mysql
124
- indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references]
190
+ indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references] || attributes[:sortable] || attributes.key?(:default_value)
125
191
  indexed ? "varchar(191)" : "text"
126
192
  elsif dialect == :mssql
127
193
  indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references] || attributes[:sortable]
@@ -164,8 +230,9 @@ module BetterAuth
164
230
  end
165
231
 
166
232
  def foreign_key_constraint(table_name, column, reference, dialect, tables = nil)
167
- target_model = tables&.fetch(reference.fetch(:model).to_s, nil)&.fetch(:model_name) || reference.fetch(:model)
168
- target_field = reference.fetch(:field)
233
+ target_table = foreign_key_target_table(reference, tables)
234
+ target_model = target_table&.fetch(:model_name) || reference.fetch(:model)
235
+ target_field = foreign_key_target_field(reference, target_table)
169
236
  on_delete = reference[:on_delete] ? " ON DELETE #{reference[:on_delete].to_s.upcase}" : ""
170
237
 
171
238
  case dialect
@@ -178,6 +245,28 @@ module BetterAuth
178
245
  end
179
246
  end
180
247
 
248
+ def foreign_key_target_table(reference, tables)
249
+ return unless tables
250
+
251
+ model = reference.fetch(:model).to_s
252
+ tables.fetch(model, nil) || tables.each_value.find { |table| table.fetch(:model_name).to_s == model }
253
+ end
254
+
255
+ def foreign_key_target_field(reference, target_table)
256
+ field = reference.fetch(:field).to_s
257
+ return field unless target_table
258
+
259
+ fields = target_table.fetch(:fields)
260
+ attributes = fields.fetch(field, nil)
261
+ return attributes[:field_name] || physical_name(field) if attributes
262
+
263
+ if fields.each_value.any? { |data| data[:field_name].to_s == field }
264
+ field
265
+ else
266
+ physical_name(field)
267
+ end
268
+ end
269
+
181
270
  def quote(identifier, dialect)
182
271
  case dialect
183
272
  when :postgres, :sqlite
@@ -18,6 +18,7 @@ module BetterAuth
18
18
  tables.delete("verification") if secondary_storage?(options) && !verification_option(options, :store_in_database)
19
19
  tables.merge!(plugin_schema)
20
20
  tables["rateLimit"] = rate_limit_table(options) if rate_limit_option(options, :storage) == "database"
21
+ ensure_id_fields!(tables)
21
22
  tables.sort_by { |_name, table| table[:order] || Float::INFINITY }.to_h
22
23
  end
23
24
 
@@ -121,6 +122,15 @@ module BetterAuth
121
122
  }
122
123
  end
123
124
 
125
+ private_class_method def self.ensure_id_fields!(tables)
126
+ tables.each_value do |table|
127
+ fields = table.fetch(:fields)
128
+ next if fields.key?("id")
129
+
130
+ table[:fields] = id_field.merge(fields)
131
+ end
132
+ end
133
+
124
134
  private_class_method def self.base_fields
125
135
  id_field.merge(timestamp_fields)
126
136
  end
@@ -162,14 +172,19 @@ module BetterAuth
162
172
  schema.each do |raw_key, raw_table|
163
173
  key = storage_key(raw_key)
164
174
  table_data = symbolize_hash(raw_table || {})
165
- existing = tables[key] || {model_name: table_data[:model_name] || physical_name(key), fields: {}}
166
- existing[:model_name] = table_data[:model_name] || existing[:model_name] || physical_name(key)
175
+ existing = tables[key] || {model_name: table_data[:model_name] || physical_table_name(key), fields: {}}
176
+ existing[:model_name] = table_data[:model_name] || existing[:model_name] || physical_table_name(key)
167
177
  existing[:fields] = existing[:fields].merge(normalize_fields(table_data[:fields] || {}))
178
+ existing[:fields] = id_field.merge(existing[:fields]) unless core_table?(key) || existing[:fields].key?("id")
168
179
  tables[key] = existing
169
180
  end
170
181
  end
171
182
  end
172
183
 
184
+ private_class_method def self.core_table?(key)
185
+ %w[user session account verification].include?(key.to_s)
186
+ end
187
+
173
188
  private_class_method def self.normalize_fields(fields)
174
189
  fields.each_with_object({}) do |(raw_key, raw_value), result|
175
190
  key = storage_key(raw_key)
@@ -270,6 +285,24 @@ module BetterAuth
270
285
  underscore(value.to_s)
271
286
  end
272
287
 
288
+ private_class_method def self.physical_table_name(value)
289
+ pluralize_table_name(physical_name(value))
290
+ end
291
+
292
+ private_class_method def self.pluralize_table_name(value)
293
+ special = {
294
+ "apikey" => "api_keys",
295
+ "api_key" => "api_keys",
296
+ "wallet_address" => "wallet_addresses"
297
+ }
298
+ return special.fetch(value) if special.key?(value)
299
+ return value if value.end_with?("s")
300
+ return "#{value[0...-1]}ies" if value.end_with?("y") && value.match?(/[^aeiou]y\z/)
301
+ return "#{value}es" if value.match?(/(s|x|z|ch|sh)\z/)
302
+
303
+ "#{value}s"
304
+ end
305
+
273
306
  private_class_method def self.camelize_lower(value)
274
307
  parts = underscore(value).split("_")
275
308
  ([parts.first] + parts.drop(1).map(&:capitalize)).join
@@ -45,7 +45,8 @@ module BetterAuth
45
45
  strategy: config[:strategy] || "compact",
46
46
  version: config[:version],
47
47
  cookie_prefix: ctx.context.options.advanced[:cookie_prefix] || "better-auth",
48
- is_secure: ctx.context.auth_cookies[:session_data].name.start_with?(Cookies::SECURE_COOKIE_PREFIX)
48
+ is_secure: ctx.context.auth_cookies[:session_data].name.start_with?(Cookies::SECURE_COOKIE_PREFIX),
49
+ cookie_full_name: ctx.context.auth_cookies[:session_data].name
49
50
  )
50
51
  return nil unless payload
51
52
  return nil if payload["session"]["token"] && payload["session"]["token"] != token
@@ -7,6 +7,7 @@ require "net/http"
7
7
  require "openssl"
8
8
  require "time"
9
9
  require "uri"
10
+ require_relative "../http_client"
10
11
 
11
12
  module BetterAuth
12
13
  module SocialProviders
@@ -67,11 +68,7 @@ module BetterAuth
67
68
  client_secret: client_secret
68
69
  )
69
70
  end,
70
- verify_id_token: opts[:verify_id_token] || lambda do |token, _nonce = nil|
71
- return false if opts[:disable_id_token_sign_in]
72
-
73
- !decode_jwt_payload(token).empty?
74
- end,
71
+ verify_id_token: opts[:verify_id_token],
75
72
  get_user_info: lambda do |tokens|
76
73
  custom = opts[:get_user_info]
77
74
  profile = if custom
@@ -277,7 +274,7 @@ module BetterAuth
277
274
  return nil if max_age && payload["iat"] && payload["iat"].to_i < Time.now.to_i - max_age.to_i
278
275
 
279
276
  payload
280
- rescue JWT::DecodeError, JSON::ParserError, ArgumentError, OpenSSL::PKey::PKeyError
277
+ rescue JWT::DecodeError, JSON::ParserError, ArgumentError, OpenSSL::PKey::PKeyError, Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError
281
278
  nil
282
279
  end
283
280
 
@@ -312,9 +309,7 @@ module BetterAuth
312
309
  end
313
310
 
314
311
  def request_json(uri, request)
315
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
316
- http.request(request)
317
- end
312
+ HTTPClient.request(uri, request)
318
313
  end
319
314
 
320
315
  def padded_base64(value)
@@ -28,7 +28,7 @@ module BetterAuth
28
28
  },
29
29
  **options
30
30
  )
31
- provider[:verify_id_token] = provider[:options][:verify_id_token] || ->(token, _nonce = nil) { provider[:options][:disable_id_token_sign_in] ? false : !token.to_s.empty? }
31
+ provider.delete(:verify_id_token) unless provider[:verify_id_token]
32
32
  provider
33
33
  end
34
34
  end
@@ -43,6 +43,8 @@ module BetterAuth
43
43
  "User-Agent" => "better-auth"
44
44
  }
45
45
  profile = Base.get_json(user_info_endpoint, headers)
46
+ next nil unless profile
47
+
46
48
  emails = Base.get_json(emails_endpoint, headers)
47
49
  primary = Array(emails).find { |email| email["email"] == profile["email"] } ||
48
50
  Array(emails).find { |email| email["primary"] } ||
@@ -26,7 +26,7 @@ module BetterAuth
26
26
  },
27
27
  **options
28
28
  )
29
- provider[:verify_id_token] = provider[:options][:verify_id_token] || ->(token, _nonce = nil) { provider[:options][:disable_id_token_sign_in] ? false : !Base.decode_jwt_payload(token).empty? }
29
+ provider.delete(:verify_id_token) unless provider[:verify_id_token]
30
30
  provider
31
31
  end
32
32
  end
@@ -29,7 +29,7 @@ module BetterAuth
29
29
  },
30
30
  **options
31
31
  )
32
- provider[:verify_id_token] = provider[:options][:verify_id_token] || ->(token, _nonce = nil) { provider[:options][:disable_id_token_sign_in] ? false : !!Base.decode_jwt_payload(token)["sub"] }
32
+ provider.delete(:verify_id_token) unless provider[:verify_id_token]
33
33
  provider
34
34
  end
35
35
  end