better_auth 0.8.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +4 -4
- data/lib/better_auth/adapters/memory.rb +131 -17
- data/lib/better_auth/adapters/sql.rb +139 -57
- data/lib/better_auth/configuration.rb +7 -1
- data/lib/better_auth/cookies.rb +11 -3
- data/lib/better_auth/doctor.rb +97 -0
- data/lib/better_auth/endpoint.rb +88 -5
- data/lib/better_auth/http_client.rb +46 -0
- data/lib/better_auth/migration_plan.rb +15 -0
- data/lib/better_auth/oauth2.rb +1 -1
- data/lib/better_auth/plugins/admin.rb +6 -1
- data/lib/better_auth/plugins/anonymous.rb +2 -0
- data/lib/better_auth/plugins/captcha.rb +1 -1
- data/lib/better_auth/plugins/device_authorization.rb +34 -0
- data/lib/better_auth/plugins/dub.rb +8 -0
- data/lib/better_auth/plugins/generic_oauth.rb +34 -7
- data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
- data/lib/better_auth/plugins/jwt.rb +10 -3
- data/lib/better_auth/plugins/mcp/schema.rb +13 -13
- data/lib/better_auth/plugins/mcp.rb +41 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +98 -21
- data/lib/better_auth/plugins/oidc_provider.rb +62 -3
- data/lib/better_auth/plugins/one_tap.rb +17 -5
- data/lib/better_auth/plugins/open_api.rb +42 -2
- data/lib/better_auth/plugins/organization.rb +122 -11
- data/lib/better_auth/plugins/phone_number.rb +1 -1
- data/lib/better_auth/plugins/two_factor.rb +21 -0
- data/lib/better_auth/rate_limiter.rb +7 -2
- data/lib/better_auth/routes/account.rb +4 -0
- data/lib/better_auth/routes/email_verification.rb +5 -1
- data/lib/better_auth/routes/password.rb +1 -0
- data/lib/better_auth/routes/social.rb +29 -1
- data/lib/better_auth/routes/user.rb +6 -2
- data/lib/better_auth/schema/sql.rb +104 -15
- data/lib/better_auth/schema.rb +35 -2
- data/lib/better_auth/session.rb +2 -1
- data/lib/better_auth/social_providers/base.rb +4 -9
- data/lib/better_auth/social_providers/facebook.rb +1 -1
- data/lib/better_auth/social_providers/github.rb +2 -0
- data/lib/better_auth/social_providers/line.rb +1 -1
- data/lib/better_auth/social_providers/paypal.rb +1 -1
- data/lib/better_auth/sql_migration.rb +566 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +3 -0
- metadata +10 -6
|
@@ -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]
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
32
|
+
provider.delete(:verify_id_token) unless provider[:verify_id_token]
|
|
33
33
|
provider
|
|
34
34
|
end
|
|
35
35
|
end
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "better_auth/migration_plan"
|
|
5
|
+
|
|
6
|
+
module BetterAuth
|
|
7
|
+
module SQLMigration
|
|
8
|
+
DEFAULT_MIGRATIONS_PATH = "db/better_auth/migrate"
|
|
9
|
+
MISSING_MIGRATIONS_TABLE_MESSAGES = [
|
|
10
|
+
/no such table/i,
|
|
11
|
+
/relation .* does not exist/i,
|
|
12
|
+
/table .* doesn't exist/i,
|
|
13
|
+
/undefined table/i,
|
|
14
|
+
/invalid object name/i
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
class UnsupportedAdapterError < StandardError; end
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def render(options, dialect:, generator:)
|
|
22
|
+
dialect = normalize_dialect(dialect)
|
|
23
|
+
config = configuration_for(options)
|
|
24
|
+
statements = BetterAuth::Schema::SQL.create_statements(config, dialect: dialect)
|
|
25
|
+
[
|
|
26
|
+
"-- Generated by #{generator}",
|
|
27
|
+
"-- Dialect: #{dialect}",
|
|
28
|
+
"",
|
|
29
|
+
statements.join("\n\n"),
|
|
30
|
+
""
|
|
31
|
+
].join("\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def plan(options, connection:, dialect:)
|
|
35
|
+
dialect = normalize_dialect(dialect)
|
|
36
|
+
plan_from_existing(options, existing: current_schema(connection, dialect), dialect: dialect)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def plan_from_existing(options, existing:, dialect:)
|
|
40
|
+
dialect = normalize_dialect(dialect)
|
|
41
|
+
config = configuration_for(options)
|
|
42
|
+
desired = BetterAuth::Schema.auth_tables(config)
|
|
43
|
+
to_create = []
|
|
44
|
+
to_add = []
|
|
45
|
+
to_index = []
|
|
46
|
+
warnings = []
|
|
47
|
+
|
|
48
|
+
desired.each do |logical_name, table|
|
|
49
|
+
table_name = table.fetch(:model_name)
|
|
50
|
+
existing_table = existing[table_name]
|
|
51
|
+
|
|
52
|
+
unless existing_table
|
|
53
|
+
to_create << BetterAuth::MigrationPlan::TableChange.new(
|
|
54
|
+
logical_name: logical_name,
|
|
55
|
+
table_name: table_name,
|
|
56
|
+
table: table,
|
|
57
|
+
order: table[:order] || Float::INFINITY
|
|
58
|
+
)
|
|
59
|
+
table.fetch(:fields).each do |field, attributes|
|
|
60
|
+
next unless indexable_field?(attributes, dialect)
|
|
61
|
+
|
|
62
|
+
column = attributes[:field_name] || physical_name(field)
|
|
63
|
+
to_index << index_change(table_name, column, attributes, dialect: dialect, unique: !!attributes[:unique])
|
|
64
|
+
end
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
missing_fields = {}
|
|
69
|
+
table.fetch(:fields).each do |field, attributes|
|
|
70
|
+
column = attributes[:field_name] || physical_name(field)
|
|
71
|
+
existing_type = existing_table.fetch(:columns)[column]
|
|
72
|
+
if existing_type.nil?
|
|
73
|
+
missing_fields[field] = attributes
|
|
74
|
+
elsif !matching_type?(existing_type, attributes[:type], dialect)
|
|
75
|
+
warnings << "Type mismatch for #{table_name}.#{column}: expected #{attributes[:type]} but found #{existing_type.to_s.downcase}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
next unless attributes[:index] || attributes[:unique]
|
|
79
|
+
next if index_present?(existing_table, column, unique: !!attributes[:unique])
|
|
80
|
+
|
|
81
|
+
to_index << index_change(table_name, column, attributes, dialect: dialect, unique: !!attributes[:unique])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
next if missing_fields.empty?
|
|
85
|
+
|
|
86
|
+
to_add << BetterAuth::MigrationPlan::FieldChange.new(
|
|
87
|
+
logical_name: logical_name,
|
|
88
|
+
table_name: table_name,
|
|
89
|
+
fields: missing_fields,
|
|
90
|
+
table: table,
|
|
91
|
+
order: table[:order] || Float::INFINITY
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
BetterAuth::MigrationPlan::Plan.new(
|
|
96
|
+
to_create: to_create.sort_by(&:order),
|
|
97
|
+
to_add: to_add.sort_by(&:order),
|
|
98
|
+
to_index: to_index,
|
|
99
|
+
warnings: warnings,
|
|
100
|
+
dialect: dialect,
|
|
101
|
+
tables: desired
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def render_pending(options, connection:, dialect:, generator:)
|
|
106
|
+
dialect = normalize_dialect(dialect)
|
|
107
|
+
migration_plan = plan(options, connection: connection, dialect: dialect)
|
|
108
|
+
statements = BetterAuth::Schema::SQL.pending_statements(migration_plan)
|
|
109
|
+
return "" if statements.empty?
|
|
110
|
+
|
|
111
|
+
[
|
|
112
|
+
"-- Generated by #{generator}",
|
|
113
|
+
"-- Dialect: #{dialect}",
|
|
114
|
+
"",
|
|
115
|
+
statements.join("\n\n"),
|
|
116
|
+
""
|
|
117
|
+
].join("\n")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def generate(options, dialect:, generator:, migrations_path: DEFAULT_MIGRATIONS_PATH, timestamp: Time.now.utc.strftime("%Y%m%d%H%M%S"), connection: nil)
|
|
121
|
+
dialect = normalize_dialect(dialect)
|
|
122
|
+
FileUtils.mkdir_p(migrations_path)
|
|
123
|
+
path = File.join(migrations_path, "#{timestamp}_create_better_auth_tables.sql")
|
|
124
|
+
return path if File.exist?(path)
|
|
125
|
+
|
|
126
|
+
sql = if connection
|
|
127
|
+
render_pending(options, connection: connection, dialect: dialect, generator: generator)
|
|
128
|
+
else
|
|
129
|
+
render(options, dialect: dialect, generator: generator)
|
|
130
|
+
end
|
|
131
|
+
return nil if sql.empty?
|
|
132
|
+
|
|
133
|
+
File.write(path, sql)
|
|
134
|
+
path
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def migrate(auth_or_options, migrations_path: DEFAULT_MIGRATIONS_PATH)
|
|
138
|
+
auth = auth_for(auth_or_options)
|
|
139
|
+
adapter = auth.context.adapter
|
|
140
|
+
unless adapter.respond_to?(:dialect) && adapter.respond_to?(:connection)
|
|
141
|
+
raise UnsupportedAdapterError, "Better Auth SQL migrations require core SQL adapters with connection and dialect support"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
connection = adapter.connection
|
|
145
|
+
dialect = normalize_dialect(adapter.dialect)
|
|
146
|
+
files = Dir[File.join(migrations_path, "*.sql")].sort
|
|
147
|
+
ensure_schema_migrations!(connection, dialect)
|
|
148
|
+
applied = applied_migrations(connection, dialect)
|
|
149
|
+
|
|
150
|
+
files.reject { |file| applied.include?(File.basename(file)) }.each do |file|
|
|
151
|
+
execute_sql(connection, File.read(file))
|
|
152
|
+
record_migration(connection, dialect, File.basename(file))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def migrate_pending(auth_or_options)
|
|
157
|
+
auth = auth_for(auth_or_options)
|
|
158
|
+
adapter = auth.context.adapter
|
|
159
|
+
unless adapter.respond_to?(:dialect) && adapter.respond_to?(:connection)
|
|
160
|
+
raise UnsupportedAdapterError, "Better Auth SQL migrations require core SQL adapters with connection and dialect support"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
connection = adapter.connection
|
|
164
|
+
dialect = normalize_dialect(adapter.dialect)
|
|
165
|
+
sql = render_pending(auth.options, connection: connection, dialect: dialect, generator: "better_auth")
|
|
166
|
+
return false if sql.empty?
|
|
167
|
+
|
|
168
|
+
if adapter.respond_to?(:transaction)
|
|
169
|
+
adapter.transaction { execute_sql(connection, sql) }
|
|
170
|
+
else
|
|
171
|
+
execute_sql(connection, sql)
|
|
172
|
+
end
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def configuration_for(options)
|
|
177
|
+
return options.options if options.is_a?(BetterAuth::Auth)
|
|
178
|
+
return options if options.is_a?(BetterAuth::Configuration)
|
|
179
|
+
|
|
180
|
+
BetterAuth::Configuration.new(options)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def auth_for(value)
|
|
184
|
+
return value if value.is_a?(BetterAuth::Auth)
|
|
185
|
+
|
|
186
|
+
BetterAuth.auth(value)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def ensure_schema_migrations!(connection, dialect)
|
|
190
|
+
sql = case dialect
|
|
191
|
+
when :postgres, :sqlite
|
|
192
|
+
%(CREATE TABLE IF NOT EXISTS #{quote("better_auth_schema_migrations", dialect)} (#{quote("version", dialect)} text PRIMARY KEY);)
|
|
193
|
+
when :mysql
|
|
194
|
+
%(CREATE TABLE IF NOT EXISTS #{quote("better_auth_schema_migrations", dialect)} (#{quote("version", dialect)} varchar(191) PRIMARY KEY) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;)
|
|
195
|
+
when :mssql
|
|
196
|
+
%(IF OBJECT_ID(N'#{quote("better_auth_schema_migrations", dialect)}', N'U') IS NULL CREATE TABLE #{quote("better_auth_schema_migrations", dialect)} (#{quote("version", dialect)} varchar(255) PRIMARY KEY);)
|
|
197
|
+
else
|
|
198
|
+
raise UnsupportedAdapterError, "Unsupported SQL dialect for Better Auth migrations: #{dialect}"
|
|
199
|
+
end
|
|
200
|
+
execute_sql(connection, sql)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def applied_migrations(connection, dialect)
|
|
204
|
+
rows = execute_sql(connection, "SELECT #{quote("version", dialect)} FROM #{quote("better_auth_schema_migrations", dialect)};")
|
|
205
|
+
Array(rows).map { |row| row["version"] || row[:version] }
|
|
206
|
+
rescue UnsupportedAdapterError
|
|
207
|
+
raise
|
|
208
|
+
rescue => error
|
|
209
|
+
raise error unless missing_schema_migrations_table?(error)
|
|
210
|
+
|
|
211
|
+
[]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def record_migration(connection, dialect, version)
|
|
215
|
+
sql = "INSERT INTO #{quote("better_auth_schema_migrations", dialect)} (#{quote("version", dialect)}) VALUES (#{literal(version)});"
|
|
216
|
+
execute_sql(connection, sql)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def execute_sql(connection, sql)
|
|
220
|
+
statements(sql).each_with_object([]) do |statement, results|
|
|
221
|
+
result =
|
|
222
|
+
if connection.respond_to?(:exec)
|
|
223
|
+
connection.exec(statement)
|
|
224
|
+
elsif connection.respond_to?(:execute)
|
|
225
|
+
connection.execute(statement)
|
|
226
|
+
elsif connection.respond_to?(:query)
|
|
227
|
+
connection.query(statement)
|
|
228
|
+
else
|
|
229
|
+
raise UnsupportedAdapterError, "SQL connection does not support exec, execute, or query"
|
|
230
|
+
end
|
|
231
|
+
results.concat(result.to_a) if result.respond_to?(:to_a)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def statements(sql)
|
|
236
|
+
normalized = sql.to_s.gsub("\r\n", "\n").strip
|
|
237
|
+
return [] if normalized.empty?
|
|
238
|
+
|
|
239
|
+
split_sql_statements(normalized)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def split_sql_statements(sql)
|
|
243
|
+
output = []
|
|
244
|
+
buffer = +""
|
|
245
|
+
index = 0
|
|
246
|
+
quote = nil
|
|
247
|
+
line_comment = false
|
|
248
|
+
block_comment = false
|
|
249
|
+
dollar_tag = nil
|
|
250
|
+
|
|
251
|
+
while index < sql.length
|
|
252
|
+
state = {
|
|
253
|
+
quote: quote,
|
|
254
|
+
line_comment: line_comment,
|
|
255
|
+
block_comment: block_comment,
|
|
256
|
+
dollar_tag: dollar_tag
|
|
257
|
+
}
|
|
258
|
+
index, quote, line_comment, block_comment, dollar_tag = scan_sql_character(sql, buffer, index, state, output)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
tail = buffer.strip
|
|
262
|
+
output << tail unless tail.empty?
|
|
263
|
+
output
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def scan_sql_character(sql, buffer, index, state, output)
|
|
267
|
+
char = sql[index]
|
|
268
|
+
next_char = sql[index + 1]
|
|
269
|
+
quote = state[:quote]
|
|
270
|
+
line_comment = state[:line_comment]
|
|
271
|
+
block_comment = state[:block_comment]
|
|
272
|
+
dollar_tag = state[:dollar_tag]
|
|
273
|
+
|
|
274
|
+
return scan_line_comment(buffer, index, char) + [quote, false, block_comment, dollar_tag] if line_comment && char == "\n"
|
|
275
|
+
return scan_line_comment(buffer, index, char) + [quote, true, block_comment, dollar_tag] if line_comment
|
|
276
|
+
return scan_block_comment(buffer, index, char, next_char, quote, line_comment, dollar_tag) if block_comment
|
|
277
|
+
return scan_dollar_quote(sql, buffer, index, char, quote, line_comment, block_comment, dollar_tag) if dollar_tag
|
|
278
|
+
return scan_quoted_string(sql, buffer, index, char, quote, line_comment, block_comment, dollar_tag) if quote
|
|
279
|
+
return [index + 2, quote, true, block_comment, dollar_tag].tap { buffer << char << next_char } if char == "-" && next_char == "-"
|
|
280
|
+
return [index + 2, quote, line_comment, true, dollar_tag].tap { buffer << char << next_char } if char == "/" && next_char == "*"
|
|
281
|
+
|
|
282
|
+
tag = dollar_quote_tag_at(sql, index)
|
|
283
|
+
return [index + tag.length, quote, line_comment, block_comment, tag].tap { buffer << tag } if tag
|
|
284
|
+
return [index + 1, char, line_comment, block_comment, dollar_tag].tap { buffer << char } if char == "'" || char == "\""
|
|
285
|
+
|
|
286
|
+
if char == ";"
|
|
287
|
+
statement = buffer.strip
|
|
288
|
+
output << statement unless statement.empty?
|
|
289
|
+
buffer.clear
|
|
290
|
+
return [index + 1, quote, line_comment, block_comment, dollar_tag]
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
buffer << char
|
|
294
|
+
[index + 1, quote, line_comment, block_comment, dollar_tag]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def scan_line_comment(buffer, index, char)
|
|
298
|
+
buffer << char
|
|
299
|
+
[index + 1]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def scan_block_comment(buffer, index, char, next_char, quote, line_comment, dollar_tag)
|
|
303
|
+
buffer << char
|
|
304
|
+
if char == "*" && next_char == "/"
|
|
305
|
+
buffer << next_char
|
|
306
|
+
[index + 2, quote, line_comment, false, dollar_tag]
|
|
307
|
+
else
|
|
308
|
+
[index + 1, quote, line_comment, true, dollar_tag]
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def scan_dollar_quote(sql, buffer, index, char, quote, line_comment, block_comment, dollar_tag)
|
|
313
|
+
if sql[index, dollar_tag.length] == dollar_tag
|
|
314
|
+
buffer << dollar_tag
|
|
315
|
+
[index + dollar_tag.length, quote, line_comment, block_comment, nil]
|
|
316
|
+
else
|
|
317
|
+
buffer << char
|
|
318
|
+
[index + 1, quote, line_comment, block_comment, dollar_tag]
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def scan_quoted_string(sql, buffer, index, char, quote, line_comment, block_comment, dollar_tag)
|
|
323
|
+
buffer << char
|
|
324
|
+
if char == quote && sql[index + 1] == quote
|
|
325
|
+
buffer << sql[index + 1]
|
|
326
|
+
[index + 2, quote, line_comment, block_comment, dollar_tag]
|
|
327
|
+
elsif char == quote
|
|
328
|
+
[index + 1, nil, line_comment, block_comment, dollar_tag]
|
|
329
|
+
else
|
|
330
|
+
[index + 1, quote, line_comment, block_comment, dollar_tag]
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def dollar_quote_tag_at(sql, index)
|
|
335
|
+
match = sql[index..]&.match(/\A\$[A-Za-z_][A-Za-z0-9_]*\$|\A\$\$/)
|
|
336
|
+
match&.[](0)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def missing_schema_migrations_table?(error)
|
|
340
|
+
message = error.message.to_s
|
|
341
|
+
MISSING_MIGRATIONS_TABLE_MESSAGES.any? { |pattern| message.match?(pattern) }
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def quote(identifier, dialect)
|
|
345
|
+
BetterAuth::Schema::SQL.quote(identifier, dialect)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def literal(value)
|
|
349
|
+
"'#{value.to_s.gsub("'", "''")}'"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def normalize_dialect(value)
|
|
353
|
+
dialect = case value.to_s.downcase
|
|
354
|
+
when "postgresql"
|
|
355
|
+
:postgres
|
|
356
|
+
when "sqlite3"
|
|
357
|
+
:sqlite
|
|
358
|
+
else
|
|
359
|
+
value.to_sym
|
|
360
|
+
end
|
|
361
|
+
return dialect if [:postgres, :sqlite, :mysql, :mssql].include?(dialect)
|
|
362
|
+
|
|
363
|
+
raise UnsupportedAdapterError, "Unsupported SQL dialect for Better Auth migrations: #{dialect}"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def current_schema(connection, dialect)
|
|
367
|
+
case dialect
|
|
368
|
+
when :sqlite
|
|
369
|
+
sqlite_schema(connection)
|
|
370
|
+
when :postgres
|
|
371
|
+
information_schema(connection, dialect, postgres_columns_sql, postgres_indexes_sql)
|
|
372
|
+
when :mysql
|
|
373
|
+
information_schema(connection, dialect, mysql_columns_sql, mysql_indexes_sql)
|
|
374
|
+
when :mssql
|
|
375
|
+
information_schema(connection, dialect, mssql_columns_sql, mssql_indexes_sql)
|
|
376
|
+
else
|
|
377
|
+
raise UnsupportedAdapterError, "Unsupported SQL dialect for Better Auth migrations: #{dialect}"
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def sqlite_schema(connection)
|
|
382
|
+
table_rows = execute_sql(connection, "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%';")
|
|
383
|
+
table_rows.each_with_object({}) do |row, schema|
|
|
384
|
+
table_name = row["name"] || row[:name]
|
|
385
|
+
columns = execute_sql(connection, "PRAGMA table_info(#{quote(table_name, :sqlite)});").each_with_object({}) do |column, result|
|
|
386
|
+
result[column["name"] || column[:name]] = column["type"] || column[:type]
|
|
387
|
+
end
|
|
388
|
+
indexes = empty_index_metadata
|
|
389
|
+
execute_sql(connection, "PRAGMA index_list(#{quote(table_name, :sqlite)});").each do |index|
|
|
390
|
+
index_name = index["name"] || index[:name]
|
|
391
|
+
unique = (index["unique"] || index[:unique]).to_i == 1
|
|
392
|
+
indexes[:names] << index_name
|
|
393
|
+
execute_sql(connection, "PRAGMA index_info(#{quote(index_name, :sqlite)});").each do |field|
|
|
394
|
+
column = field["name"] || field[:name]
|
|
395
|
+
indexes[:columns] << column
|
|
396
|
+
indexes[:unique_columns] << column if unique
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
schema[table_name] = {name: table_name, columns: columns, indexes: indexes}
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def information_schema(connection, dialect, columns_sql, indexes_sql)
|
|
404
|
+
schema = {}
|
|
405
|
+
execute_sql(connection, columns_sql).each do |row|
|
|
406
|
+
table_name = row_value(row, "table_name")
|
|
407
|
+
column_name = row_value(row, "column_name")
|
|
408
|
+
data_type = row_value(row, "data_type")
|
|
409
|
+
schema[table_name] ||= {name: table_name, columns: {}, indexes: empty_index_metadata}
|
|
410
|
+
schema[table_name][:columns][column_name] = data_type
|
|
411
|
+
end
|
|
412
|
+
execute_sql(connection, indexes_sql).each do |row|
|
|
413
|
+
table_name = row_value(row, "table_name")
|
|
414
|
+
column_name = row_value(row, "column_name")
|
|
415
|
+
index_name = row_value(row, "index_name")
|
|
416
|
+
unique_value = row_value(row, "unique") || row_value(row, "is_unique")
|
|
417
|
+
non_unique_value = row_value(row, "non_unique")
|
|
418
|
+
unique = (!unique_value.nil?) ? truthy_database_value?(unique_value) : non_unique_value.to_i == 0
|
|
419
|
+
schema[table_name] ||= {name: table_name, columns: {}, indexes: empty_index_metadata}
|
|
420
|
+
schema[table_name][:indexes][:names] << index_name if index_name
|
|
421
|
+
schema[table_name][:indexes][:columns] << column_name if column_name
|
|
422
|
+
schema[table_name][:indexes][:unique_columns] << column_name if column_name && !!unique
|
|
423
|
+
end
|
|
424
|
+
schema
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def row_value(row, key)
|
|
428
|
+
candidates = [
|
|
429
|
+
key,
|
|
430
|
+
key.to_sym,
|
|
431
|
+
key.upcase,
|
|
432
|
+
key.upcase.to_sym,
|
|
433
|
+
camelize_lower(key),
|
|
434
|
+
camelize_lower(key).to_sym
|
|
435
|
+
]
|
|
436
|
+
candidates.each { |candidate| return row[candidate] if row.key?(candidate) }
|
|
437
|
+
nil
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def truthy_database_value?(value)
|
|
441
|
+
return value if value == true || value == false
|
|
442
|
+
|
|
443
|
+
value.to_i != 0
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def camelize_lower(value)
|
|
447
|
+
parts = value.to_s.split("_")
|
|
448
|
+
([parts.first] + parts.drop(1).map(&:capitalize)).join
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def empty_index_metadata
|
|
452
|
+
{names: Set.new, columns: Set.new, unique_columns: Set.new}
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def index_change(table_name, column, attributes, dialect:, unique:)
|
|
456
|
+
BetterAuth::MigrationPlan::IndexChange.new(
|
|
457
|
+
table_name: table_name,
|
|
458
|
+
field_name: column,
|
|
459
|
+
name: index_name(table_name, column, attributes, dialect),
|
|
460
|
+
unique: unique,
|
|
461
|
+
field: attributes
|
|
462
|
+
)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def indexable_field?(attributes, dialect)
|
|
466
|
+
attributes[:index] || filtered_unique_index?(attributes, dialect)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def filtered_unique_index?(attributes, dialect)
|
|
470
|
+
dialect == :mssql && attributes[:unique] && !attributes[:required]
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def index_name(table_name, column, attributes, dialect)
|
|
474
|
+
filtered_unique_index?(attributes, dialect) ? "uniq_#{table_name}_#{column}" : "index_#{table_name}_on_#{column}"
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def index_present?(table, column, unique:)
|
|
478
|
+
indexes = table.fetch(:indexes)
|
|
479
|
+
if unique
|
|
480
|
+
indexes[:unique_columns].include?(column) || indexes[:names].include?("index_#{table.fetch(:name, "")}_on_#{column}")
|
|
481
|
+
else
|
|
482
|
+
indexes[:columns].include?(column)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def matching_type?(actual_type, expected_type, dialect)
|
|
487
|
+
normalized = actual_type.to_s.downcase.split("(").first.strip
|
|
488
|
+
type = expected_type.to_s
|
|
489
|
+
expected = case type
|
|
490
|
+
when "string"
|
|
491
|
+
%w[text varchar character varying char uuid nvarchar uniqueidentifier]
|
|
492
|
+
when "number"
|
|
493
|
+
%w[integer int int4 bigint smallint numeric real decimal float double]
|
|
494
|
+
when "boolean"
|
|
495
|
+
%w[bool boolean integer tinyint bit smallint]
|
|
496
|
+
when "date"
|
|
497
|
+
%w[timestamptz timestamp date datetime datetime2]
|
|
498
|
+
when "json", "string[]", "number[]"
|
|
499
|
+
(dialect == :postgres) ? %w[json jsonb text] : %w[json text varchar nvarchar]
|
|
500
|
+
else
|
|
501
|
+
[normalized]
|
|
502
|
+
end
|
|
503
|
+
expected.include?(normalized)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def postgres_columns_sql
|
|
507
|
+
<<~SQL
|
|
508
|
+
SELECT table_name, column_name, data_type
|
|
509
|
+
FROM information_schema.columns
|
|
510
|
+
WHERE table_schema = current_schema();
|
|
511
|
+
SQL
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def postgres_indexes_sql
|
|
515
|
+
<<~SQL
|
|
516
|
+
SELECT t.relname AS table_name, a.attname AS column_name, i.relname AS index_name, ix.indisunique AS unique
|
|
517
|
+
FROM pg_class t
|
|
518
|
+
JOIN pg_index ix ON t.oid = ix.indrelid
|
|
519
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
520
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
|
|
521
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
522
|
+
WHERE t.relkind = 'r' AND n.nspname = current_schema();
|
|
523
|
+
SQL
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def mysql_columns_sql
|
|
527
|
+
<<~SQL
|
|
528
|
+
SELECT table_name, column_name, data_type
|
|
529
|
+
FROM information_schema.columns
|
|
530
|
+
WHERE table_schema = DATABASE();
|
|
531
|
+
SQL
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def mysql_indexes_sql
|
|
535
|
+
<<~SQL
|
|
536
|
+
SELECT table_name, column_name, index_name, CASE WHEN non_unique = 0 THEN 1 ELSE 0 END AS is_unique
|
|
537
|
+
FROM information_schema.statistics
|
|
538
|
+
WHERE table_schema = DATABASE();
|
|
539
|
+
SQL
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def mssql_columns_sql
|
|
543
|
+
<<~SQL
|
|
544
|
+
SELECT t.name AS table_name, c.name AS column_name, ty.name AS data_type
|
|
545
|
+
FROM sys.tables t
|
|
546
|
+
JOIN sys.columns c ON c.object_id = t.object_id
|
|
547
|
+
JOIN sys.types ty ON ty.user_type_id = c.user_type_id;
|
|
548
|
+
SQL
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def mssql_indexes_sql
|
|
552
|
+
<<~SQL
|
|
553
|
+
SELECT t.name AS table_name, c.name AS column_name, i.name AS index_name, i.is_unique
|
|
554
|
+
FROM sys.tables t
|
|
555
|
+
JOIN sys.indexes i ON i.object_id = t.object_id
|
|
556
|
+
JOIN sys.index_columns ic ON ic.object_id = t.object_id AND ic.index_id = i.index_id
|
|
557
|
+
JOIN sys.columns c ON c.object_id = t.object_id AND c.column_id = ic.column_id
|
|
558
|
+
WHERE i.name IS NOT NULL;
|
|
559
|
+
SQL
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def physical_name(value)
|
|
563
|
+
BetterAuth::Schema.send(:physical_name, value)
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
end
|
data/lib/better_auth/version.rb
CHANGED
data/lib/better_auth.rb
CHANGED
|
@@ -15,12 +15,15 @@ require_relative "better_auth/async"
|
|
|
15
15
|
require_relative "better_auth/deprecate"
|
|
16
16
|
require_relative "better_auth/logger"
|
|
17
17
|
require_relative "better_auth/instrumentation"
|
|
18
|
+
require_relative "better_auth/http_client"
|
|
18
19
|
require_relative "better_auth/oauth2"
|
|
19
20
|
require_relative "better_auth/password"
|
|
20
21
|
require_relative "better_auth/plugin"
|
|
21
22
|
require_relative "better_auth/configuration"
|
|
22
23
|
require_relative "better_auth/schema"
|
|
23
24
|
require_relative "better_auth/schema/sql"
|
|
25
|
+
require_relative "better_auth/migration_plan"
|
|
26
|
+
require_relative "better_auth/doctor"
|
|
24
27
|
require_relative "better_auth/adapters/base"
|
|
25
28
|
require_relative "better_auth/adapters/join_support"
|
|
26
29
|
require_relative "better_auth/adapters/memory"
|