better_auth 0.8.0 → 0.11.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +142 -95
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +303 -54
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +390 -63
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +48 -47
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +119 -15
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +108 -0
  18. data/lib/better_auth/endpoint.rb +88 -5
  19. data/lib/better_auth/error.rb +3 -1
  20. data/lib/better_auth/http_client.rb +46 -0
  21. data/lib/better_auth/logger.rb +1 -1
  22. data/lib/better_auth/middleware/origin_check.rb +9 -1
  23. data/lib/better_auth/migration_plan.rb +15 -0
  24. data/lib/better_auth/oauth2.rb +1 -1
  25. data/lib/better_auth/oauth_state.rb +147 -0
  26. data/lib/better_auth/plugin_loader.rb +220 -0
  27. data/lib/better_auth/plugins/access.rb +10 -13
  28. data/lib/better_auth/plugins/admin.rb +85 -29
  29. data/lib/better_auth/plugins/anonymous.rb +2 -0
  30. data/lib/better_auth/plugins/api_key.rb +4 -7
  31. data/lib/better_auth/plugins/bearer.rb +8 -22
  32. data/lib/better_auth/plugins/captcha.rb +24 -6
  33. data/lib/better_auth/plugins/device_authorization.rb +77 -5
  34. data/lib/better_auth/plugins/dub.rb +10 -2
  35. data/lib/better_auth/plugins/email_otp.rb +30 -47
  36. data/lib/better_auth/plugins/generic_oauth.rb +162 -140
  37. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  38. data/lib/better_auth/plugins/i18n.rb +215 -0
  39. data/lib/better_auth/plugins/jwt.rb +44 -19
  40. data/lib/better_auth/plugins/magic_link.rb +18 -21
  41. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  42. data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
  43. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  44. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  45. data/lib/better_auth/plugins/one_tap.rb +78 -65
  46. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  47. data/lib/better_auth/plugins/open_api.rb +77 -9
  48. data/lib/better_auth/plugins/organization.rb +546 -102
  49. data/lib/better_auth/plugins/passkey.rb +4 -7
  50. data/lib/better_auth/plugins/phone_number.rb +25 -27
  51. data/lib/better_auth/plugins/scim.rb +4 -7
  52. data/lib/better_auth/plugins/siwe.rb +126 -16
  53. data/lib/better_auth/plugins/sso.rb +4 -7
  54. data/lib/better_auth/plugins/stripe.rb +4 -7
  55. data/lib/better_auth/plugins/two_factor.rb +205 -19
  56. data/lib/better_auth/plugins/username.rb +56 -13
  57. data/lib/better_auth/plugins.rb +57 -0
  58. data/lib/better_auth/rate_limiter.rb +300 -133
  59. data/lib/better_auth/request_ip.rb +123 -13
  60. data/lib/better_auth/router.rb +3 -3
  61. data/lib/better_auth/routes/account.rb +44 -8
  62. data/lib/better_auth/routes/email_verification.rb +93 -6
  63. data/lib/better_auth/routes/password.rb +6 -6
  64. data/lib/better_auth/routes/session.rb +7 -2
  65. data/lib/better_auth/routes/sign_in.rb +10 -1
  66. data/lib/better_auth/routes/sign_up.rb +45 -22
  67. data/lib/better_auth/routes/social.rb +197 -54
  68. data/lib/better_auth/routes/user.rb +17 -10
  69. data/lib/better_auth/schema/sql.rb +189 -34
  70. data/lib/better_auth/schema.rb +159 -5
  71. data/lib/better_auth/session.rb +29 -10
  72. data/lib/better_auth/session_store.rb +1 -1
  73. data/lib/better_auth/social_providers/apple.rb +6 -1
  74. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  75. data/lib/better_auth/social_providers/base.rb +45 -23
  76. data/lib/better_auth/social_providers/cognito.rb +75 -5
  77. data/lib/better_auth/social_providers/facebook.rb +95 -1
  78. data/lib/better_auth/social_providers/figma.rb +2 -0
  79. data/lib/better_auth/social_providers/github.rb +2 -0
  80. data/lib/better_auth/social_providers/google.rb +12 -1
  81. data/lib/better_auth/social_providers/line.rb +35 -1
  82. data/lib/better_auth/social_providers/linear.rb +1 -0
  83. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  84. data/lib/better_auth/social_providers/naver.rb +24 -1
  85. data/lib/better_auth/social_providers/notion.rb +1 -0
  86. data/lib/better_auth/social_providers/paybin.rb +25 -1
  87. data/lib/better_auth/social_providers/paypal.rb +118 -2
  88. data/lib/better_auth/social_providers/reddit.rb +25 -4
  89. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  90. data/lib/better_auth/social_providers/twitch.rb +24 -1
  91. data/lib/better_auth/social_providers/twitter.rb +36 -1
  92. data/lib/better_auth/social_providers/vk.rb +34 -1
  93. data/lib/better_auth/social_providers/wechat.rb +6 -4
  94. data/lib/better_auth/sql_migration.rb +566 -0
  95. data/lib/better_auth/version.rb +1 -1
  96. data/lib/better_auth.rb +14 -41
  97. metadata +14 -18
  98. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  99. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  100. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  101. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  102. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  103. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  104. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  105. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  106. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  107. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  108. data/lib/better_auth/plugins/mcp.rb +0 -215
  109. data/lib/better_auth/plugins/oidc_provider.rb +0 -710
@@ -7,18 +7,47 @@ module BetterAuth
7
7
 
8
8
  def create_statements(options, dialect:)
9
9
  dialect = dialect.to_sym
10
- tables = Schema.auth_tables(options)
11
- statements = tables.map { |logical_name, table| create_table_statement(logical_name, table, dialect, tables) }
10
+ tables = Schema.migration_tables(options)
11
+ delete_actions = mssql_delete_actions(tables, dialect)
12
+ statements = tables.map do |logical_name, table|
13
+ create_table_statement(logical_name, table, dialect, tables, delete_actions: delete_actions)
14
+ end
12
15
  statements.concat(tables.flat_map { |_logical_name, table| index_statements(table, dialect) })
13
16
  end
14
17
 
15
- def create_table_statement(logical_name, table, dialect, tables = nil)
18
+ def pending_statements(plan)
19
+ delete_actions = mssql_delete_actions(plan.tables, plan.dialect)
20
+ statements = plan.to_create.map do |change|
21
+ create_table_statement(change.logical_name, change.table, plan.dialect, plan.tables, delete_actions: delete_actions)
22
+ end
23
+ statements.concat(plan.to_add.flat_map do |change|
24
+ change.fields.map do |logical_field, attributes|
25
+ if logical_name(logical_field, attributes) == "id" && plan.dialect == :postgres
26
+ add_postgres_id_column_statements(change.table_name)
27
+ else
28
+ add_column_statement(change.table_name, logical_field, attributes, plan.dialect)
29
+ end
30
+ end
31
+ end.flatten)
32
+ statements.concat(plan.to_index.map do |change|
33
+ index_statement(
34
+ change.table_name,
35
+ change.field_name,
36
+ change.name,
37
+ plan.dialect,
38
+ unique: change.unique,
39
+ where_not_null: filtered_unique_index?(change.field, plan.dialect)
40
+ )
41
+ end)
42
+ end
43
+
44
+ def create_table_statement(logical_name, table, dialect, tables = nil, delete_actions: nil)
16
45
  table_name = table.fetch(:model_name)
17
46
  columns = table.fetch(:fields).map do |logical_field, attributes|
18
47
  column_definition(table_name, logical_field, attributes, dialect)
19
48
  end
20
49
  constraints = table.fetch(:fields).flat_map do |logical_field, attributes|
21
- field_constraints(table_name, logical_field, attributes, dialect, tables)
50
+ field_constraints(table_name, logical_field, attributes, dialect, tables, delete_actions: delete_actions)
22
51
  end
23
52
  body = (columns + constraints).join(",\n ")
24
53
 
@@ -28,7 +57,7 @@ module BetterAuth
28
57
  when :mysql
29
58
  %(CREATE TABLE IF NOT EXISTS #{quote(table_name, dialect)} (\n #{body}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;)
30
59
  when :mssql
31
- %(IF OBJECT_ID(N'#{quote(table_name, dialect)}', N'U') IS NULL\nCREATE TABLE #{quote(table_name, dialect)} (\n #{body}\n);)
60
+ %(#{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
61
  else
33
62
  raise ArgumentError, "Unsupported SQL dialect: #{dialect}"
34
63
  end
@@ -36,8 +65,8 @@ module BetterAuth
36
65
 
37
66
  def column_definition(table_name, logical_field, attributes, dialect)
38
67
  column = quote(attributes[:field_name] || physical_name(logical_field), dialect)
39
- parts = [column, sql_type(logical_field, attributes, dialect)]
40
- parts << "PRIMARY KEY" if logical_field == "id"
68
+ parts = [column, sql_type(logical_name(logical_field, attributes), attributes, dialect)]
69
+ parts << "PRIMARY KEY" if logical_name(logical_field, attributes) == "id"
41
70
  if attributes[:required]
42
71
  parts << "NOT NULL"
43
72
  elsif dialect == :mssql
@@ -48,17 +77,17 @@ module BetterAuth
48
77
  parts.join(" ")
49
78
  end
50
79
 
51
- def field_constraints(table_name, logical_field, attributes, dialect, tables = nil)
80
+ def field_constraints(table_name, logical_field, attributes, dialect, tables = nil, delete_actions: nil)
52
81
  constraints = []
53
82
  column = attributes[:field_name] || physical_name(logical_field)
54
83
 
55
- if attributes[:unique] && logical_field != "id"
84
+ if attributes[:unique] && logical_name(logical_field, attributes) != "id" && !(dialect == :mssql && !attributes[:required])
56
85
  constraints << unique_constraint(table_name, column, dialect)
57
86
  end
58
87
 
59
88
  reference = attributes[:references]
60
89
  if reference
61
- constraints << foreign_key_constraint(table_name, column, reference, dialect, tables)
90
+ constraints << foreign_key_constraint(table_name, column, reference, dialect, tables, delete_actions: delete_actions)
62
91
  end
63
92
 
64
93
  constraints
@@ -67,23 +96,65 @@ module BetterAuth
67
96
  def index_statements(table, dialect)
68
97
  table_name = table.fetch(:model_name)
69
98
  table.fetch(:fields).filter_map do |logical_field, attributes|
70
- next unless attributes[:index]
99
+ nullable_unique_mssql = dialect == :mssql && attributes[:unique] && logical_name(logical_field, attributes) != "id" && !attributes[:required]
100
+ next if attributes[:unique] && !nullable_unique_mssql
101
+ next unless attributes[:index] || nullable_unique_mssql
71
102
 
72
103
  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
104
+ unique = attributes[:unique] && dialect == :mssql
105
+ name = unique ? "uniq_#{table_name}_#{column}" : "index_#{table_name}_on_#{column}"
106
+ index_statement(table_name, column, name, dialect, unique: unique, where_not_null: filtered_unique_index?(attributes, dialect))
82
107
  end
83
108
  end
84
109
 
110
+ def add_column_statement(table_name, logical_field, attributes, dialect)
111
+ keyword = (dialect == :mssql) ? "ADD" : "ADD COLUMN"
112
+ %(ALTER TABLE #{quote(table_name, dialect)} #{keyword} #{column_definition(table_name, logical_field, attributes, dialect)};)
113
+ end
114
+
115
+ def add_postgres_id_column_statements(table_name)
116
+ quoted_table = quote(table_name, :postgres)
117
+ quoted_id = quote("id", :postgres)
118
+ [
119
+ %(ALTER TABLE #{quoted_table} ADD COLUMN #{quoted_id} text;),
120
+ %(UPDATE #{quoted_table} SET #{quoted_id} = md5(random()::text || clock_timestamp()::text || ctid::text) WHERE #{quoted_id} IS NULL;),
121
+ %(ALTER TABLE #{quoted_table} ALTER COLUMN #{quoted_id} SET NOT NULL;),
122
+ %(ALTER TABLE #{quoted_table} ADD PRIMARY KEY (#{quoted_id});)
123
+ ]
124
+ end
125
+
126
+ def index_statement(table_name, column, name, dialect, unique: false, where_not_null: false)
127
+ unique_prefix = unique ? "UNIQUE " : ""
128
+ case dialect
129
+ when :postgres, :sqlite
130
+ %(CREATE #{unique_prefix}INDEX IF NOT EXISTS #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
131
+ when :mysql
132
+ %(CREATE #{unique_prefix}INDEX #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
133
+ when :mssql
134
+ filter = where_not_null ? " WHERE #{quote(column, dialect)} IS NOT NULL" : ""
135
+ %(#{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};)
136
+ end
137
+ end
138
+
139
+ def filtered_unique_index?(attributes, dialect)
140
+ dialect == :mssql && attributes[:unique] && !attributes[:required]
141
+ end
142
+
143
+ def mssql_required_set_options
144
+ <<~SQL.strip
145
+ SET ANSI_NULLS ON;
146
+ SET QUOTED_IDENTIFIER ON;
147
+ SET ANSI_WARNINGS ON;
148
+ SET ANSI_PADDING ON;
149
+ SET CONCAT_NULL_YIELDS_NULL ON;
150
+ SET ARITHABORT ON;
151
+ SET NUMERIC_ROUNDABORT OFF;
152
+ SQL
153
+ end
154
+
85
155
  def sql_type(logical_field, attributes, dialect)
86
- case attributes[:type]
156
+ type = attributes[:type] || "string"
157
+ case type
87
158
  when "boolean"
88
159
  case dialect
89
160
  when :mysql
@@ -119,16 +190,22 @@ module BetterAuth
119
190
  else
120
191
  "text"
121
192
  end
193
+ when "string"
194
+ indexed_string_sql_type(logical_field, attributes, dialect)
122
195
  else
123
- if dialect == :mysql
124
- indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references]
125
- indexed ? "varchar(191)" : "text"
126
- elsif dialect == :mssql
127
- indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references] || attributes[:sortable]
128
- indexed ? "varchar(255)" : "varchar(8000)"
129
- else
130
- "text"
131
- end
196
+ raise BetterAuth::Error, "Unsupported field type: #{type}"
197
+ end
198
+ end
199
+
200
+ def indexed_string_sql_type(logical_field, attributes, dialect)
201
+ if dialect == :mysql
202
+ indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references] || attributes[:sortable] || attributes.key?(:default_value)
203
+ indexed ? "varchar(191)" : "text"
204
+ elsif dialect == :mssql
205
+ indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references] || attributes[:sortable]
206
+ indexed ? "varchar(255)" : "varchar(8000)"
207
+ else
208
+ "text"
132
209
  end
133
210
  end
134
211
 
@@ -163,10 +240,12 @@ module BetterAuth
163
240
  end
164
241
  end
165
242
 
166
- 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)
169
- on_delete = reference[:on_delete] ? " ON DELETE #{reference[:on_delete].to_s.upcase}" : ""
243
+ def foreign_key_constraint(table_name, column, reference, dialect, tables = nil, delete_actions: nil)
244
+ target_table = foreign_key_target_table(reference, tables)
245
+ target_model = target_table&.fetch(:model_name) || reference.fetch(:model)
246
+ target_field = foreign_key_target_field(reference, target_table)
247
+ delete_action = delete_actions&.fetch([table_name.to_s, column.to_s], reference[:on_delete]) || reference[:on_delete]
248
+ on_delete = delete_action ? " ON DELETE #{delete_action.to_s.upcase}" : ""
170
249
 
171
250
  case dialect
172
251
  when :postgres, :sqlite
@@ -178,6 +257,78 @@ module BetterAuth
178
257
  end
179
258
  end
180
259
 
260
+ def mssql_delete_actions(tables, dialect)
261
+ return unless dialect.to_sym == :mssql
262
+
263
+ graph = {}
264
+ tables.each_with_object({}) do |(_logical_name, table), actions|
265
+ child = table.fetch(:model_name).to_s
266
+ table.fetch(:fields).each do |logical_field, attributes|
267
+ reference = attributes[:references]
268
+ next unless reference
269
+
270
+ column = (attributes[:field_name] || physical_name(logical_field)).to_s
271
+ action = reference[:on_delete]&.to_s
272
+ key = [child, column]
273
+ unless mssql_cascading_action?(action)
274
+ actions[key] = action
275
+ next
276
+ end
277
+
278
+ target_table = foreign_key_target_table(reference, tables)
279
+ parent = (target_table&.fetch(:model_name) || reference.fetch(:model)).to_s
280
+ if mssql_cascade_conflict?(graph, parent, child)
281
+ actions[key] = "no action"
282
+ else
283
+ graph[parent] ||= []
284
+ graph[parent] << child unless graph[parent].include?(child)
285
+ actions[key] = action
286
+ end
287
+ end
288
+ end
289
+ end
290
+
291
+ def mssql_cascading_action?(action)
292
+ %w[cascade set\ null set\ default].include?(action.to_s.downcase.tr("_", " "))
293
+ end
294
+
295
+ def mssql_cascade_conflict?(graph, parent, child)
296
+ nodes = (graph.keys + graph.values.flatten + [parent, child]).uniq
297
+ nodes.any? do |source|
298
+ mssql_reachable?(graph, source, parent) && mssql_reachable?(graph, source, child)
299
+ end
300
+ end
301
+
302
+ def mssql_reachable?(graph, source, target, visited = {})
303
+ return true if source == target
304
+ return false if visited[source]
305
+
306
+ visited[source] = true
307
+ Array(graph[source]).any? { |child| mssql_reachable?(graph, child, target, visited) }
308
+ end
309
+
310
+ def foreign_key_target_table(reference, tables)
311
+ return unless tables
312
+
313
+ model = reference.fetch(:model).to_s
314
+ tables.fetch(model, nil) || tables.each_value.find { |table| table.fetch(:model_name).to_s == model }
315
+ end
316
+
317
+ def foreign_key_target_field(reference, target_table)
318
+ field = reference.fetch(:field).to_s
319
+ return field unless target_table
320
+
321
+ fields = target_table.fetch(:fields)
322
+ attributes = fields.fetch(field, nil)
323
+ return attributes[:field_name] || physical_name(field) if attributes
324
+
325
+ if fields.each_value.any? { |data| data[:field_name].to_s == field }
326
+ field
327
+ else
328
+ physical_name(field)
329
+ end
330
+ end
331
+
181
332
  def quote(identifier, dialect)
182
333
  case dialect
183
334
  when :postgres, :sqlite
@@ -197,6 +348,10 @@ module BetterAuth
197
348
  .tr("-", "_")
198
349
  .downcase
199
350
  end
351
+
352
+ def logical_name(logical_field, attributes)
353
+ (attributes[:logical_name] || logical_field).to_s
354
+ end
200
355
  end
201
356
  end
202
357
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  module BetterAuth
4
6
  module Schema
5
7
  module_function
@@ -18,9 +20,44 @@ module BetterAuth
18
20
  tables.delete("verification") if secondary_storage?(options) && !verification_option(options, :store_in_database)
19
21
  tables.merge!(plugin_schema)
20
22
  tables["rateLimit"] = rate_limit_table(options) if rate_limit_option(options, :storage) == "database"
23
+ ensure_id_fields!(tables)
21
24
  tables.sort_by { |_name, table| table[:order] || Float::INFINITY }.to_h
22
25
  end
23
26
 
27
+ def migration_tables(options)
28
+ tables = auth_tables(options)
29
+ grouped = {}
30
+
31
+ tables.each_with_index do |(logical_name, table), index|
32
+ table_name = table.fetch(:model_name)
33
+ target = grouped[table_name] ||= {
34
+ model_name: table_name,
35
+ fields: {},
36
+ order: table[:order] || Float::INFINITY,
37
+ source_order: index,
38
+ logical_names: [],
39
+ disable_migration: false
40
+ }
41
+ target[:order] = [target[:order], table[:order] || Float::INFINITY].min
42
+ target[:source_order] = [target[:source_order], index].min
43
+ target[:logical_names] << logical_name
44
+ target[:disable_migration] ||= table[:disable_migration] == true
45
+
46
+ table.fetch(:fields).each do |logical_field, attributes|
47
+ column = attributes[:field_name] || physical_name(logical_field)
48
+ physical_attributes = physical_field_attributes(logical_field, attributes, tables)
49
+ merge_physical_field!(target, column, physical_attributes)
50
+ end
51
+ end
52
+
53
+ grouped
54
+ .reject { |_name, table| table[:disable_migration] }
55
+ .sort_by { |_name, table| [table[:order], table[:source_order]] }
56
+ .to_h do |name, table|
57
+ [name, table.except(:source_order, :disable_migration)]
58
+ end
59
+ end
60
+
24
61
  def storage_model_name(options, model)
25
62
  table = auth_tables(options).fetch(model.to_s)
26
63
  table[:model_name]
@@ -48,10 +85,48 @@ module BetterAuth
48
85
  data
49
86
  end
50
87
 
88
+ def parse_provider_profile_user_input(options, profile, action:)
89
+ action = action.to_sym
90
+ raise ArgumentError, "action must be :create or :update" unless [:create, :update].include?(action)
91
+
92
+ input = (profile || {}).each_with_object({}) do |(key, value), result|
93
+ result[storage_key(key)] = value
94
+ end
95
+ core_fields = %w[id email emailVerified name image createdAt updatedAt]
96
+ fields = auth_tables(options).fetch("user").fetch(:fields).except(*core_fields)
97
+
98
+ fields.each_with_object({}) do |(field, attributes), result|
99
+ provider_value_allowed = attributes[:input] != false && input.key?(field)
100
+ if provider_value_allowed
101
+ value = coerce_provider_profile_value(input[field], attributes)
102
+ transform = attributes[:transform]
103
+ value = transform.call(value) if transform.respond_to?(:call)
104
+ validator = attributes[:validator]
105
+ if validator.respond_to?(:call) && !validator.call(value)
106
+ raise APIError.new("BAD_REQUEST", message: "#{field} is invalid")
107
+ end
108
+ result[field] = value
109
+ elsif action == :create && attributes.key?(:default_value)
110
+ default = attributes[:default_value]
111
+ result[field] = default.respond_to?(:call) ? default.call : default
112
+ elsif action == :create && attributes[:required]
113
+ raise APIError.new("BAD_REQUEST", message: "#{field} is required")
114
+ end
115
+ end
116
+ end
117
+
118
+ private_class_method def self.coerce_provider_profile_value(value, attributes)
119
+ return value if value.nil?
120
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
121
+
122
+ value
123
+ end
124
+
51
125
  private_class_method def self.user_table(options, plugin_table)
52
126
  table(
53
127
  model_name: model_option(options, :user, :model_name) || "users",
54
128
  order: 1,
129
+ disable_migration: plugin_table&.fetch(:disable_migration, false),
55
130
  fields: id_field.merge(
56
131
  "name" => field("string", required: true, sortable: true, field_name: mapped_field(options, :user, "name")),
57
132
  "email" => field("string", required: true, unique: true, sortable: true, field_name: mapped_field(options, :user, "email")),
@@ -66,6 +141,7 @@ module BetterAuth
66
141
  table(
67
142
  model_name: model_option(options, :session, :model_name) || "sessions",
68
143
  order: 2,
144
+ disable_migration: plugin_table&.fetch(:disable_migration, false),
69
145
  fields: base_fields.merge(
70
146
  "expiresAt" => field("date", required: true, field_name: mapped_field(options, :session, "expiresAt")),
71
147
  "token" => field("string", required: true, unique: true, field_name: mapped_field(options, :session, "token")),
@@ -81,6 +157,7 @@ module BetterAuth
81
157
  table(
82
158
  model_name: model_option(options, :account, :model_name) || "accounts",
83
159
  order: 3,
160
+ disable_migration: plugin_table&.fetch(:disable_migration, false),
84
161
  fields: base_fields.merge(
85
162
  "accountId" => field("string", required: true, field_name: mapped_field(options, :account, "accountId")),
86
163
  "providerId" => field("string", required: true, field_name: mapped_field(options, :account, "providerId")),
@@ -101,6 +178,7 @@ module BetterAuth
101
178
  table(
102
179
  model_name: model_option(options, :verification, :model_name) || "verifications",
103
180
  order: 4,
181
+ disable_migration: plugin_table&.fetch(:disable_migration, false),
104
182
  fields: base_fields.merge(
105
183
  "identifier" => field("string", required: true, index: true, field_name: mapped_field(options, :verification, "identifier")),
106
184
  "value" => field("string", required: true, field_name: mapped_field(options, :verification, "value")),
@@ -121,6 +199,58 @@ module BetterAuth
121
199
  }
122
200
  end
123
201
 
202
+ private_class_method def self.ensure_id_fields!(tables)
203
+ tables.each_value do |table|
204
+ fields = table.fetch(:fields)
205
+ next if fields.key?("id")
206
+
207
+ table[:fields] = id_field.merge(fields)
208
+ end
209
+ end
210
+
211
+ private_class_method def self.physical_field_attributes(logical_field, attributes, tables)
212
+ column = attributes[:field_name] || physical_name(logical_field)
213
+ physical_attributes = attributes.merge(field_name: column, logical_name: logical_field.to_s)
214
+ reference = attributes[:references]
215
+ return physical_attributes unless reference
216
+
217
+ physical_attributes.merge(references: physical_reference(reference, tables))
218
+ end
219
+
220
+ private_class_method def self.physical_reference(reference, tables)
221
+ model = reference.fetch(:model).to_s
222
+ target_table = tables.fetch(model, nil) || tables.each_value.find { |table| table.fetch(:model_name).to_s == model }
223
+ return reference unless target_table
224
+
225
+ field = reference.fetch(:field).to_s
226
+ target_field = target_table.fetch(:fields).fetch(field, nil)
227
+ physical_field = if target_field
228
+ target_field[:field_name] || physical_name(field)
229
+ elsif target_table.fetch(:fields).each_value.any? { |attributes| attributes[:field_name].to_s == field }
230
+ field
231
+ else
232
+ physical_name(field)
233
+ end
234
+ reference.merge(model: target_table.fetch(:model_name), field: physical_field)
235
+ end
236
+
237
+ private_class_method def self.merge_physical_field!(table, column, attributes)
238
+ existing = table.fetch(:fields)[column]
239
+ unless existing
240
+ table.fetch(:fields)[column] = attributes
241
+ return
242
+ end
243
+
244
+ return if compatible_physical_field?(existing, attributes)
245
+
246
+ raise BetterAuth::Error, "Conflicting schema metadata for #{table.fetch(:model_name)}.#{column}"
247
+ end
248
+
249
+ private_class_method def self.compatible_physical_field?(left, right)
250
+ keys = [:type, :required, :references, :index, :unique, :sortable, :bigint]
251
+ keys.all? { |key| left[key] == right[key] }
252
+ end
253
+
124
254
  private_class_method def self.base_fields
125
255
  id_field.merge(timestamp_fields)
126
256
  end
@@ -138,12 +268,12 @@ module BetterAuth
138
268
  }
139
269
  end
140
270
 
141
- private_class_method def self.table(model_name:, fields:, extra_fields:, order:)
271
+ private_class_method def self.table(model_name:, fields:, extra_fields:, order:, disable_migration: false)
142
272
  {
143
273
  model_name: model_name,
144
274
  fields: merge_fields(fields, *extra_fields),
145
275
  order: order
146
- }
276
+ }.tap { |data| data[:disable_migration] = true if disable_migration == true }
147
277
  end
148
278
 
149
279
  private_class_method def self.merge_fields(base, *extras)
@@ -162,14 +292,20 @@ module BetterAuth
162
292
  schema.each do |raw_key, raw_table|
163
293
  key = storage_key(raw_key)
164
294
  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)
295
+ existing = tables[key] || {model_name: table_data[:model_name] || physical_table_name(key), fields: {}}
296
+ existing[:model_name] = table_data[:model_name] || existing[:model_name] || physical_table_name(key)
167
297
  existing[:fields] = existing[:fields].merge(normalize_fields(table_data[:fields] || {}))
298
+ existing[:disable_migration] = true if table_data[:disable_migration] == true
299
+ existing[:fields] = id_field.merge(existing[:fields]) unless core_table?(key) || existing[:fields].key?("id")
168
300
  tables[key] = existing
169
301
  end
170
302
  end
171
303
  end
172
304
 
305
+ private_class_method def self.core_table?(key)
306
+ %w[user session account verification].include?(key.to_s)
307
+ end
308
+
173
309
  private_class_method def self.normalize_fields(fields)
174
310
  fields.each_with_object({}) do |(raw_key, raw_value), result|
175
311
  key = storage_key(raw_key)
@@ -270,6 +406,24 @@ module BetterAuth
270
406
  underscore(value.to_s)
271
407
  end
272
408
 
409
+ private_class_method def self.physical_table_name(value)
410
+ pluralize_table_name(physical_name(value))
411
+ end
412
+
413
+ private_class_method def self.pluralize_table_name(value)
414
+ special = {
415
+ "apikey" => "api_keys",
416
+ "api_key" => "api_keys",
417
+ "wallet_address" => "wallet_addresses"
418
+ }
419
+ return special.fetch(value) if special.key?(value)
420
+ return value if value.end_with?("s")
421
+ return "#{value[0...-1]}ies" if value.end_with?("y") && value.match?(/[^aeiou]y\z/)
422
+ return "#{value}es" if value.match?(/(s|x|z|ch|sh)\z/)
423
+
424
+ "#{value}s"
425
+ end
426
+
273
427
  private_class_method def self.camelize_lower(value)
274
428
  parts = underscore(value).split("_")
275
429
  ([parts.first] + parts.drop(1).map(&:capitalize)).join
@@ -286,6 +440,6 @@ module BetterAuth
286
440
  (Time.now.to_f * 1000).to_i
287
441
  end
288
442
 
289
- public_class_method :storage_key
443
+ public_class_method :physical_name, :storage_key
290
444
  end
291
445
  end
@@ -7,15 +7,21 @@ module BetterAuth
7
7
  module_function
8
8
 
9
9
  def find_current(ctx, disable_cookie_cache: false, disable_refresh: false, sensitive: false)
10
- if ctx.context.current_session
10
+ authoritative = sensitive && stateful?(ctx)
11
+ if ctx.context.current_session && !authoritative
11
12
  return ctx.context.current_session
12
13
  end
13
14
 
14
15
  token_cookie = ctx.context.auth_cookies[:session_token]
15
16
  token = ctx.get_signed_cookie(token_cookie.name, ctx.context.secret)
16
- return nil unless token
17
+ unless token
18
+ present = ctx.get_cookie(token_cookie.name)
19
+ return missing_session(ctx) if present && !present.empty?
17
20
 
18
- cached = cached_session(ctx, token, disable_cookie_cache: disable_cookie_cache, sensitive: sensitive)
21
+ return nil
22
+ end
23
+
24
+ cached = cached_session(ctx, token, disable_cookie_cache: disable_cookie_cache, sensitive: authoritative)
19
25
  if cached
20
26
  ctx.context.set_current_session(cached) if ctx.context.respond_to?(:set_current_session)
21
27
  return cached
@@ -45,7 +51,8 @@ module BetterAuth
45
51
  strategy: config[:strategy] || "compact",
46
52
  version: config[:version],
47
53
  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)
54
+ is_secure: ctx.context.auth_cookies[:session_data].name.start_with?(Cookies::SECURE_COOKIE_PREFIX),
55
+ cookie_full_name: ctx.context.auth_cookies[:session_data].name
49
56
  )
50
57
  return nil unless payload
51
58
  return nil if payload["session"]["token"] && payload["session"]["token"] != token
@@ -56,10 +63,16 @@ module BetterAuth
56
63
  end
57
64
 
58
65
  def missing_session(ctx)
66
+ ctx.context.set_current_session(nil) if ctx.context.respond_to?(:set_current_session)
59
67
  Cookies.delete_session_cookie(ctx)
60
68
  nil
61
69
  end
62
70
 
71
+ def stateful?(ctx)
72
+ options = ctx.context.options
73
+ !options.database.nil? || !options.secondary_storage.nil?
74
+ end
75
+
63
76
  def expired?(session)
64
77
  expires_at = normalize_time(session["expiresAt"])
65
78
  expires_at && expires_at <= Time.now
@@ -83,7 +96,12 @@ module BetterAuth
83
96
  "expiresAt" => expires_at,
84
97
  "updatedAt" => now
85
98
  )
86
- session = stringify_keys(updated || result[:session]).merge("expiresAt" => expires_at, "updatedAt" => now)
99
+ unless updated
100
+ missing_session(ctx)
101
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"])
102
+ end
103
+
104
+ session = stringify_keys(updated)
87
105
  refreshed = {session: session, user: result[:user]}
88
106
  Cookies.set_session_cookie(ctx, refreshed, Cookies.dont_remember?(ctx))
89
107
  refreshed
@@ -91,12 +109,13 @@ module BetterAuth
91
109
 
92
110
  def refresh_cached_session(ctx, result)
93
111
  now = Time.now
94
- session = stringify_keys(result[:session]).merge(
95
- "expiresAt" => now + ctx.context.session_config[:expires_in].to_i,
96
- "updatedAt" => now
97
- )
112
+ session = stringify_keys(result[:session])
113
+ expires_at = normalize_time(session["expiresAt"])
114
+ return missing_session(ctx) if expires_at && expires_at <= now
115
+
116
+ remaining = expires_at && [(expires_at - now).floor, 0].max
98
117
  refreshed = {session: session, user: result[:user]}
99
- Cookies.set_session_cookie(ctx, refreshed, Cookies.dont_remember?(ctx))
118
+ Cookies.set_session_cookie(ctx, refreshed, Cookies.dont_remember?(ctx), max_age: remaining)
100
119
  refreshed
101
120
  end
102
121
 
@@ -6,7 +6,7 @@ module BetterAuth
6
6
  ESTIMATED_EMPTY_COOKIE_SIZE = 200
7
7
  CHUNK_SIZE = ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE
8
8
 
9
- CookieValue = Struct.new(:name, :value, :attributes, keyword_init: true)
9
+ CookieValue = Struct.new(:name, :value, :attributes)
10
10
 
11
11
  attr_reader :cookie_name, :cookie_options, :context, :chunks
12
12
 
@@ -45,8 +45,13 @@ module BetterAuth
45
45
  algorithms: ["RS256"],
46
46
  issuers: "https://appleid.apple.com",
47
47
  audience: audiences,
48
- nonce: nonce
48
+ nonce: nil
49
49
  )
50
+ if nonce
51
+ token_nonce = profile&.fetch("nonce", nil)
52
+ expected_nonces = [nonce, OpenSSL::Digest::SHA256.hexdigest(nonce.to_s)]
53
+ return false unless token_nonce.is_a?(String) && expected_nonces.include?(token_nonce)
54
+ end
50
55
  !!profile&.fetch("sub", nil)
51
56
  end,
52
57
  get_user_info: lambda do |tokens|
@@ -15,6 +15,7 @@ module BetterAuth
15
15
  user_info_endpoint: "https://api.atlassian.com/me",
16
16
  scopes: scopes,
17
17
  pkce: true,
18
+ require_code_verifier: true,
18
19
  auth_params: {audience: "api.atlassian.com"},
19
20
  profile_map: ->(profile) {
20
21
  {