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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +4 -4
  4. data/lib/better_auth/adapters/memory.rb +131 -17
  5. data/lib/better_auth/adapters/sql.rb +139 -57
  6. data/lib/better_auth/configuration.rb +7 -1
  7. data/lib/better_auth/cookies.rb +11 -3
  8. data/lib/better_auth/doctor.rb +97 -0
  9. data/lib/better_auth/endpoint.rb +88 -5
  10. data/lib/better_auth/http_client.rb +46 -0
  11. data/lib/better_auth/migration_plan.rb +15 -0
  12. data/lib/better_auth/oauth2.rb +1 -1
  13. data/lib/better_auth/plugins/admin.rb +6 -1
  14. data/lib/better_auth/plugins/anonymous.rb +2 -0
  15. data/lib/better_auth/plugins/captcha.rb +1 -1
  16. data/lib/better_auth/plugins/device_authorization.rb +34 -0
  17. data/lib/better_auth/plugins/dub.rb +8 -0
  18. data/lib/better_auth/plugins/generic_oauth.rb +34 -7
  19. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  20. data/lib/better_auth/plugins/jwt.rb +10 -3
  21. data/lib/better_auth/plugins/mcp/schema.rb +13 -13
  22. data/lib/better_auth/plugins/mcp.rb +41 -0
  23. data/lib/better_auth/plugins/oauth_protocol.rb +98 -21
  24. data/lib/better_auth/plugins/oidc_provider.rb +62 -3
  25. data/lib/better_auth/plugins/one_tap.rb +17 -5
  26. data/lib/better_auth/plugins/open_api.rb +42 -2
  27. data/lib/better_auth/plugins/organization.rb +122 -11
  28. data/lib/better_auth/plugins/phone_number.rb +1 -1
  29. data/lib/better_auth/plugins/two_factor.rb +21 -0
  30. data/lib/better_auth/rate_limiter.rb +7 -2
  31. data/lib/better_auth/routes/account.rb +4 -0
  32. data/lib/better_auth/routes/email_verification.rb +5 -1
  33. data/lib/better_auth/routes/password.rb +1 -0
  34. data/lib/better_auth/routes/social.rb +29 -1
  35. data/lib/better_auth/routes/user.rb +6 -2
  36. data/lib/better_auth/schema/sql.rb +104 -15
  37. data/lib/better_auth/schema.rb +35 -2
  38. data/lib/better_auth/session.rb +2 -1
  39. data/lib/better_auth/social_providers/base.rb +4 -9
  40. data/lib/better_auth/social_providers/facebook.rb +1 -1
  41. data/lib/better_auth/social_providers/github.rb +2 -0
  42. data/lib/better_auth/social_providers/line.rb +1 -1
  43. data/lib/better_auth/social_providers/paypal.rb +1 -1
  44. data/lib/better_auth/sql_migration.rb +566 -0
  45. data/lib/better_auth/version.rb +1 -1
  46. data/lib/better_auth.rb +3 -0
  47. metadata +10 -6
@@ -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,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
  }
@@ -38,6 +38,14 @@ module BetterAuth
38
38
  openapi: {
39
39
  operationId: "dubLink",
40
40
  description: "Link a Dub OAuth account",
41
+ requestBody: OpenAPI.json_request_body(
42
+ OpenAPI.object_schema(
43
+ {
44
+ callbackURL: {type: "string", description: "The URL to redirect to after linking"},
45
+ callback_url: {type: "string", description: "The URL to redirect to after linking"}
46
+ }
47
+ )
48
+ ),
41
49
  responses: {
42
50
  "200" => OpenAPI.json_response(
43
51
  "Authorization URL generated successfully for linking a Dub account",
@@ -220,6 +220,7 @@ module BetterAuth
220
220
  openapi: {
221
221
  operationId: "signInOAuth2",
222
222
  description: "Sign in with OAuth2",
223
+ requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema),
223
224
  responses: {
224
225
  "200" => OpenAPI.json_response("Sign in with OAuth2", generic_oauth_url_response_schema)
225
226
  }
@@ -242,6 +243,7 @@ module BetterAuth
242
243
  openapi: {
243
244
  operationId: "linkOAuth2",
244
245
  description: "Link an OAuth2 account to the current user session",
246
+ requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema),
245
247
  responses: {
246
248
  "200" => OpenAPI.json_response("Authorization URL generated successfully for linking an OAuth2 account", generic_oauth_url_response_schema)
247
249
  }
@@ -352,6 +354,28 @@ module BetterAuth
352
354
  )
353
355
  end
354
356
 
357
+ def generic_oauth_start_body_schema
358
+ OpenAPI.object_schema(
359
+ {
360
+ providerId: {type: "string", description: "OAuth provider ID"},
361
+ provider_id: {type: "string", description: "OAuth provider ID"},
362
+ callbackURL: {type: "string", description: "URL to redirect to after success"},
363
+ callback_url: {type: "string", description: "URL to redirect to after success"},
364
+ errorCallbackURL: {type: "string", description: "URL to redirect to after error"},
365
+ error_callback_url: {type: "string", description: "URL to redirect to after error"},
366
+ newUserCallbackURL: {type: "string", description: "URL to redirect to for new users"},
367
+ new_user_callback_url: {type: "string", description: "URL to redirect to for new users"},
368
+ requestSignUp: {type: "boolean", description: "Whether this request is a sign-up flow"},
369
+ request_sign_up: {type: "boolean", description: "Whether this request is a sign-up flow"},
370
+ scopes: {type: "array", items: {type: "string"}, description: "Additional OAuth scopes"},
371
+ disableRedirect: {type: "boolean", description: "Return the URL instead of redirecting"},
372
+ disable_redirect: {type: "boolean", description: "Return the URL instead of redirecting"},
373
+ additionalData: {type: "object", additionalProperties: true},
374
+ additional_data: {type: "object", additionalProperties: true}
375
+ }
376
+ )
377
+ end
378
+
355
379
  def generic_oauth_authorization_url(ctx, provider, body, link:)
356
380
  authorization_url = provider[:authorization_url] || generic_oauth_discovery(provider)["authorization_endpoint"]
357
381
  token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
@@ -487,14 +511,17 @@ module BetterAuth
487
511
  if verification
488
512
  cookie = ctx.context.create_auth_cookie("state")
489
513
  cookie_state = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
490
- if cookie_state && cookie_state != state
514
+ if ctx.request && cookie_state != state
515
+ Cookies.expire_cookie(ctx, cookie)
516
+ raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
517
+ elsif !ctx.request && cookie_state && cookie_state != state
491
518
  Cookies.expire_cookie(ctx, cookie)
492
519
  raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
493
520
  end
494
521
 
495
522
  parsed = JSON.parse(verification.fetch("value"))
496
523
  ctx.context.internal_adapter.delete_verification_value(verification.fetch("id"))
497
- Cookies.expire_cookie(ctx, cookie) if cookie_state
524
+ Cookies.expire_cookie(ctx, cookie) if ctx.request || cookie_state
498
525
  return parsed
499
526
  end
500
527
  end
@@ -521,7 +548,7 @@ module BetterAuth
521
548
  uri = URI(user_info_url)
522
549
  request = Net::HTTP::Get.new(uri)
523
550
  request["authorization"] = "Bearer #{fetch_value(tokens, "accessToken")}"
524
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
551
+ response = HTTPClient.request(uri, request)
525
552
  return nil unless response.is_a?(Net::HTTPSuccess)
526
553
 
527
554
  generic_oauth_normalize_user_info(JSON.parse(response.body))
@@ -615,7 +642,7 @@ module BetterAuth
615
642
  normalize_hash(provider[:discovery_headers] || provider[:discoveryHeaders]).each do |key, value|
616
643
  request[key.to_s.tr("_", "-")] = value.to_s
617
644
  end
618
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
645
+ response = HTTPClient.request(uri, request)
619
646
  provider[:_discovery] = response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : {}
620
647
  rescue
621
648
  {}
@@ -649,7 +676,7 @@ module BetterAuth
649
676
  form_data[key] = value unless form_data.key?(key)
650
677
  end
651
678
  request.set_form_data(form_data)
652
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
679
+ response = HTTPClient.request(uri, request)
653
680
  return nil unless response.is_a?(Net::HTTPSuccess)
654
681
 
655
682
  generic_oauth_normalize_tokens(JSON.parse(response.body))
@@ -714,7 +741,7 @@ module BetterAuth
714
741
  uri = URI(url)
715
742
  request = Net::HTTP::Get.new(uri)
716
743
  normalize_hash(headers).each { |key, value| request[key.to_s.tr("_", "-")] = value.to_s }
717
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
744
+ response = HTTPClient.request(uri, request)
718
745
  return nil unless response.is_a?(Net::HTTPSuccess)
719
746
 
720
747
  JSON.parse(response.body)
@@ -790,7 +817,7 @@ module BetterAuth
790
817
  token_url_params = token_url_params.call(ctx) if token_url_params.respond_to?(:call)
791
818
  normalize_hash(token_url_params || {}).each { |key, value| form_data[key] = value }
792
819
  request.set_form_data(form_data.compact)
793
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
820
+ response = HTTPClient.request(uri, request)
794
821
  raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIG"]) unless response.is_a?(Net::HTTPSuccess)
795
822
 
796
823
  generic_oauth_normalize_tokens(JSON.parse(response.body))
@@ -83,7 +83,7 @@ module BetterAuth
83
83
  request = Net::HTTP::Get.new(uri)
84
84
  request["Add-Padding"] = "true"
85
85
  request["User-Agent"] = "BetterAuth Password Checker"
86
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
86
+ response = HTTPClient.request(uri, request)
87
87
  unless response.is_a?(Net::HTTPSuccess)
88
88
  raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Status: #{response.code}")
89
89
  end
@@ -63,6 +63,7 @@ module BetterAuth
63
63
  schema: {
64
64
  jwks: {
65
65
  fields: {
66
+ id: {type: "string", required: true},
66
67
  publicKey: {type: "string", required: true},
67
68
  privateKey: {type: "string", required: true},
68
69
  createdAt: {type: "date", required: true},
@@ -279,9 +280,15 @@ module BetterAuth
279
280
  payload = if fetcher.respond_to?(:call)
280
281
  fetcher.call(url)
281
282
  else
282
- uri = URI.parse(url.to_s)
283
- response = Net::HTTP.get_response(uri)
284
- response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : nil
283
+ cached = @jwt_remote_jwks_cache ||= {}
284
+ entry = cached[url.to_s]
285
+ if entry && entry[:expires_at] > Time.now
286
+ entry[:payload]
287
+ else
288
+ fetched = HTTPClient.get_json(url)
289
+ cached[url.to_s] = {payload: fetched, expires_at: Time.now + 300} if fetched
290
+ fetched
291
+ end
285
292
  end
286
293
  keys = fetch_value(payload, "keys")
287
294
  Array(keys).map { |entry| normalize_remote_jwk(entry) }
@@ -8,7 +8,7 @@ module BetterAuth
8
8
  def schema
9
9
  {
10
10
  oauthClient: {
11
- modelName: "oauthClient",
11
+ model_name: "oauth_clients",
12
12
  fields: {
13
13
  clientId: {type: "string", unique: true, required: true},
14
14
  clientSecret: {type: "string", required: false},
@@ -17,7 +17,7 @@ module BetterAuth
17
17
  enableEndSession: {type: "boolean", required: false},
18
18
  clientSecretExpiresAt: {type: "number", required: false},
19
19
  scopes: {type: "string[]", required: false},
20
- userId: {type: "string", required: false},
20
+ userId: {type: "string", required: false, references: {model: "user", field: "id"}},
21
21
  createdAt: {type: "date", required: true, default_value: -> { Time.now }},
22
22
  updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }},
23
23
  name: {type: "string", required: false},
@@ -45,9 +45,9 @@ module BetterAuth
45
45
  oauthRefreshToken: {
46
46
  fields: {
47
47
  token: {type: "string", required: true},
48
- clientId: {type: "string", required: true},
49
- sessionId: {type: "string", required: false},
50
- userId: {type: "string", required: false},
48
+ clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
49
+ sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
50
+ userId: {type: "string", required: false, references: {model: "user", field: "id"}},
51
51
  referenceId: {type: "string", required: false},
52
52
  authTime: {type: "date", required: false},
53
53
  expiresAt: {type: "date", required: false},
@@ -57,27 +57,27 @@ module BetterAuth
57
57
  }
58
58
  },
59
59
  oauthAccessToken: {
60
- modelName: "oauthAccessToken",
60
+ model_name: "oauth_access_tokens",
61
61
  fields: {
62
62
  token: {type: "string", unique: true, required: true},
63
63
  expiresAt: {type: "date", required: true},
64
- clientId: {type: "string", required: true},
65
- userId: {type: "string", required: false},
66
- sessionId: {type: "string", required: false},
64
+ clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
65
+ userId: {type: "string", required: false, references: {model: "user", field: "id"}},
66
+ sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
67
67
  scopes: {type: "string[]", required: true},
68
68
  revoked: {type: "date", required: false},
69
69
  referenceId: {type: "string", required: false},
70
70
  authTime: {type: "date", required: false},
71
- refreshId: {type: "string", required: false},
71
+ refreshId: {type: "string", required: false, references: {model: "oauthRefreshToken", field: "id"}},
72
72
  createdAt: {type: "date", required: true, default_value: -> { Time.now }},
73
73
  updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
74
74
  }
75
75
  },
76
76
  oauthConsent: {
77
- modelName: "oauthConsent",
77
+ model_name: "oauth_consents",
78
78
  fields: {
79
- clientId: {type: "string", required: true},
80
- userId: {type: "string", required: false},
79
+ clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
80
+ userId: {type: "string", required: false, references: {model: "user", field: "id"}},
81
81
  referenceId: {type: "string", required: false},
82
82
  scopes: {type: "string[]", required: true},
83
83
  createdAt: {type: "date", required: true, default_value: -> { Time.now }},