better_auth 0.4.0 → 0.6.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +5 -5
  5. data/lib/better_auth/adapters/sql.rb +96 -18
  6. data/lib/better_auth/api.rb +113 -13
  7. data/lib/better_auth/configuration.rb +97 -7
  8. data/lib/better_auth/context.rb +165 -12
  9. data/lib/better_auth/cookies.rb +6 -4
  10. data/lib/better_auth/core.rb +2 -0
  11. data/lib/better_auth/crypto/jwe.rb +27 -5
  12. data/lib/better_auth/crypto.rb +32 -0
  13. data/lib/better_auth/database_hooks.rb +5 -5
  14. data/lib/better_auth/endpoint.rb +87 -3
  15. data/lib/better_auth/error.rb +8 -1
  16. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  17. data/lib/better_auth/plugins/admin.rb +344 -16
  18. data/lib/better_auth/plugins/anonymous.rb +37 -3
  19. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  20. data/lib/better_auth/plugins/dub.rb +148 -0
  21. data/lib/better_auth/plugins/email_otp.rb +246 -15
  22. data/lib/better_auth/plugins/expo.rb +17 -1
  23. data/lib/better_auth/plugins/generic_oauth.rb +53 -7
  24. data/lib/better_auth/plugins/jwt.rb +37 -4
  25. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  26. data/lib/better_auth/plugins/magic_link.rb +66 -3
  27. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  28. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  29. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  30. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  31. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  32. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  33. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  34. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  35. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  36. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  37. data/lib/better_auth/plugins/mcp.rb +111 -263
  38. data/lib/better_auth/plugins/multi_session.rb +61 -3
  39. data/lib/better_auth/plugins/oauth_protocol.rb +2 -2
  40. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  41. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  42. data/lib/better_auth/plugins/one_tap.rb +7 -2
  43. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  44. data/lib/better_auth/plugins/open_api.rb +163 -318
  45. data/lib/better_auth/plugins/organization.rb +135 -36
  46. data/lib/better_auth/plugins/phone_number.rb +141 -6
  47. data/lib/better_auth/plugins/siwe.rb +69 -3
  48. data/lib/better_auth/plugins/two_factor.rb +65 -23
  49. data/lib/better_auth/plugins/username.rb +57 -2
  50. data/lib/better_auth/rate_limiter.rb +20 -0
  51. data/lib/better_auth/response.rb +42 -0
  52. data/lib/better_auth/router.rb +7 -1
  53. data/lib/better_auth/routes/account.rb +204 -38
  54. data/lib/better_auth/routes/email_verification.rb +98 -14
  55. data/lib/better_auth/routes/password.rb +125 -8
  56. data/lib/better_auth/routes/session.rb +128 -13
  57. data/lib/better_auth/routes/sign_in.rb +24 -2
  58. data/lib/better_auth/routes/sign_out.rb +13 -1
  59. data/lib/better_auth/routes/sign_up.rb +62 -4
  60. data/lib/better_auth/routes/social.rb +102 -7
  61. data/lib/better_auth/routes/user.rb +222 -20
  62. data/lib/better_auth/routes/validation.rb +50 -0
  63. data/lib/better_auth/secret_config.rb +115 -0
  64. data/lib/better_auth/session.rb +1 -1
  65. data/lib/better_auth/url_helpers.rb +12 -1
  66. data/lib/better_auth/version.rb +1 -1
  67. data/lib/better_auth.rb +4 -0
  68. metadata +15 -1
@@ -5,21 +5,16 @@ require "uri"
5
5
  module BetterAuth
6
6
  class Context
7
7
  attr_reader :app_name,
8
- :base_url,
9
8
  :version,
10
9
  :options,
11
10
  :social_providers,
12
- :cookies,
13
- :auth_cookies,
14
11
  :adapter,
15
12
  :internal_adapter,
16
13
  :logger,
17
14
  :session_config,
18
15
  :rate_limit_config,
19
- :trusted_origins,
20
16
  :secret,
21
- :current_session,
22
- :new_session
17
+ :secret_config
23
18
 
24
19
  def initialize(configuration)
25
20
  @app_name = configuration.app_name
@@ -36,6 +31,7 @@ module BetterAuth
36
31
  @rate_limit_config = configuration.rate_limit
37
32
  @trusted_origins = configuration.trusted_origins
38
33
  @secret = configuration.secret
34
+ @secret_config = configuration.secret_config
39
35
  @current_session = nil
40
36
  @new_session = nil
41
37
  end
@@ -46,12 +42,36 @@ module BetterAuth
46
42
  end
47
43
  end
48
44
 
45
+ def base_url
46
+ runtime_fetch(:base_url, @base_url)
47
+ end
48
+
49
+ def trusted_origins
50
+ runtime_fetch(:trusted_origins, @trusted_origins)
51
+ end
52
+
53
+ def auth_cookies
54
+ runtime_fetch(:auth_cookies, @auth_cookies)
55
+ end
56
+
57
+ def cookies
58
+ runtime_fetch(:cookies, @cookies)
59
+ end
60
+
61
+ def current_session
62
+ runtime_fetch(:current_session, @current_session)
63
+ end
64
+
65
+ def new_session
66
+ runtime_fetch(:new_session, @new_session)
67
+ end
68
+
49
69
  def set_new_session(session)
50
- @new_session = session
70
+ runtime_store(:new_session, session) || @new_session = session
51
71
  end
52
72
 
53
73
  def set_current_session(session)
54
- @current_session = session
74
+ runtime_store(:current_session, session) || @current_session = session
55
75
  end
56
76
 
57
77
  def run_in_background(task)
@@ -63,6 +83,31 @@ module BetterAuth
63
83
  end
64
84
  end
65
85
 
86
+ def password
87
+ config = {
88
+ min_password_length: options.email_and_password[:min_password_length],
89
+ max_password_length: options.email_and_password[:max_password_length]
90
+ }
91
+ password_config = options.email_and_password[:password] || {}
92
+
93
+ {
94
+ config: config,
95
+ hash: ->(value) { Password.hash(value, hasher: password_config[:hash], algorithm: options.password_hasher) },
96
+ verify: lambda do |password:, hash:|
97
+ Password.verify(
98
+ password: password,
99
+ hash: hash,
100
+ verifier: password_config[:verify],
101
+ algorithm: options.password_hasher
102
+ )
103
+ end,
104
+ check_password: lambda do |value|
105
+ length = value.to_s.length
106
+ length.between?(config[:min_password_length].to_i, config[:max_password_length].to_i)
107
+ end
108
+ }
109
+ end
110
+
66
111
  def create_auth_cookie(cookie_name, override_attributes = {})
67
112
  Cookies.create_cookie(options, cookie_name.to_s, override_attributes)
68
113
  end
@@ -87,6 +132,7 @@ module BetterAuth
87
132
  @rate_limit_config = options.rate_limit
88
133
  @trusted_origins = options.trusted_origins
89
134
  @secret = options.secret
135
+ @secret_config = options.secret_config
90
136
  end
91
137
 
92
138
  def method_missing(name, *arguments, &block)
@@ -101,17 +147,52 @@ module BetterAuth
101
147
  end
102
148
 
103
149
  def prepare_for_request!(request)
104
- @current_session = nil
105
- @new_session = nil
106
- @base_url = inferred_base_url(request) if options.base_url.to_s.empty?
107
- @trusted_origins = current_trusted_origins(request)
150
+ runtime = request_runtime
151
+ runtime[:current_session] = nil
152
+ runtime[:new_session] = nil
153
+ if options.dynamic_base_url?
154
+ runtime[:base_url] = resolved_dynamic_base_url(request)
155
+ refresh_cookies!
156
+ elsif options.base_url.to_s.empty?
157
+ runtime[:base_url] = inferred_base_url(request)
158
+ end
159
+ runtime[:trusted_origins] = current_trusted_origins(request)
160
+ end
161
+
162
+ def prepare_for_api_call!(source)
163
+ runtime = request_runtime
164
+ runtime[:current_session] = nil
165
+ runtime[:new_session] = nil
166
+ if options.dynamic_base_url?
167
+ runtime[:base_url] = resolved_dynamic_base_url(source)
168
+ refresh_cookies!
169
+ end
170
+ runtime[:trusted_origins] = current_trusted_origins(request_for_callbacks(source))
108
171
  end
109
172
 
110
173
  def reset_runtime!
174
+ Thread.current[runtime_key] = nil if request_runtime?
175
+ options.clear_runtime_base_url! if options.respond_to?(:clear_runtime_base_url!)
111
176
  @current_session = nil
112
177
  @new_session = nil
113
178
  end
114
179
 
180
+ def clear_runtime!
181
+ Thread.current[runtime_key] = nil
182
+ options.clear_runtime_base_url! if options.respond_to?(:clear_runtime_base_url!)
183
+ end
184
+
185
+ def refresh_cookies!
186
+ cookies = Cookies.get_cookies(options)
187
+ if request_runtime?
188
+ runtime_store(:auth_cookies, cookies)
189
+ runtime_store(:cookies, cookies)
190
+ else
191
+ @auth_cookies = cookies
192
+ @cookies = cookies
193
+ end
194
+ end
195
+
115
196
  private
116
197
 
117
198
  def inferred_base_url(request)
@@ -120,6 +201,61 @@ module BetterAuth
120
201
  path.empty? ? origin : "#{origin}#{path}"
121
202
  end
122
203
 
204
+ def resolved_dynamic_base_url(request)
205
+ resolved = URLHelpers.resolve_base_url(
206
+ options.base_url_config,
207
+ options.base_path,
208
+ request,
209
+ load_env: true,
210
+ trusted_proxy_headers: dynamic_trusted_proxy_headers?
211
+ )
212
+ origin = Configuration.origin_for(URI.parse(resolved))
213
+ options.set_runtime_base_url(origin) if options.respond_to?(:set_runtime_base_url)
214
+ resolved
215
+ end
216
+
217
+ def dynamic_trusted_proxy_headers?
218
+ return true unless options.advanced.key?(:trusted_proxy_headers)
219
+
220
+ !!options.advanced[:trusted_proxy_headers]
221
+ end
222
+
223
+ def request_for_callbacks(source)
224
+ return source if source.respond_to?(:get_header)
225
+
226
+ DirectAPIRequest.new(source, base_url) if source.is_a?(Hash)
227
+ end
228
+
229
+ def request_runtime
230
+ Thread.current[runtime_key] ||= {
231
+ base_url: @base_url,
232
+ trusted_origins: @trusted_origins,
233
+ auth_cookies: @auth_cookies,
234
+ cookies: @cookies,
235
+ current_session: nil,
236
+ new_session: nil
237
+ }
238
+ end
239
+
240
+ def request_runtime?
241
+ !!Thread.current[runtime_key]
242
+ end
243
+
244
+ def runtime_fetch(key, fallback)
245
+ request_runtime? ? Thread.current[runtime_key].fetch(key, fallback) : fallback
246
+ end
247
+
248
+ def runtime_store(key, value)
249
+ return false unless request_runtime?
250
+
251
+ Thread.current[runtime_key][key] = value
252
+ true
253
+ end
254
+
255
+ def runtime_key
256
+ :"better_auth_context_runtime_#{object_id}"
257
+ end
258
+
123
259
  def inferred_origin(request)
124
260
  forwarded_host = request.get_header("HTTP_X_FORWARDED_HOST")
125
261
  forwarded_proto = request.get_header("HTTP_X_FORWARDED_PROTO")
@@ -207,5 +343,22 @@ module BetterAuth
207
343
  def plugin_context_attribute?(key)
208
344
  ![:options, :adapter, :internal_adapter].include?(key)
209
345
  end
346
+
347
+ class DirectAPIRequest
348
+ attr_reader :headers, :url
349
+
350
+ def initialize(headers, url)
351
+ @headers = headers.transform_keys { |key| key.to_s.downcase }
352
+ @url = url
353
+ end
354
+
355
+ def get_header(key)
356
+ normalized = key.to_s
357
+ .sub(/\AHTTP_/, "")
358
+ .downcase
359
+ .tr("_", "-")
360
+ headers[normalized] || headers[key.to_s] || headers[key.to_s.downcase]
361
+ end
362
+ end
210
363
  end
211
364
  end
@@ -40,7 +40,7 @@ module BetterAuth
40
40
  uri.host unless uri.host.to_s.empty?
41
41
  end
42
42
  end
43
- raise Error, "base_url is required when cross_subdomain_cookies are enabled" if cross_subdomain && domain.to_s.empty?
43
+ raise Error, "base_url is required when cross_subdomain_cookies are enabled" if cross_subdomain && domain.to_s.empty? && !options.dynamic_base_url?
44
44
 
45
45
  custom = advanced.dig(:cookies, cookie_name.to_sym) || {}
46
46
  prefix = advanced[:cookie_prefix] || "better-auth"
@@ -112,7 +112,9 @@ module BetterAuth
112
112
  cookie = ctx.context.auth_cookies[:session_data]
113
113
  max_age = dont_remember_me ? nil : cookie.attributes[:max_age]
114
114
  data = filtered_cache_data(ctx, session)
115
- value = encode_cookie_cache(data, ctx.context.secret, strategy: config[:strategy] || "compact", max_age: max_age || 60 * 5)
115
+ strategy = config[:strategy] || "compact"
116
+ secret = (strategy.to_s == "jwe") ? ctx.context.secret_config : ctx.context.secret
117
+ value = encode_cookie_cache(data, secret, strategy: strategy, max_age: max_age || 60 * 5)
116
118
  attributes = cookie.attributes.merge(max_age: max_age)
117
119
  store = SessionStore.new(cookie.name, attributes, ctx)
118
120
 
@@ -129,7 +131,7 @@ module BetterAuth
129
131
 
130
132
  cookie = ctx.context.auth_cookies[:account_data]
131
133
  attributes = cookie.attributes.merge(max_age: cookie.attributes[:max_age] || 60 * 5)
132
- value = Crypto.symmetric_encode_jwt(stringify_keys(account_data), ctx.context.secret, "better-auth-account", expires_in: attributes[:max_age])
134
+ value = Crypto.symmetric_encode_jwt(stringify_keys(account_data), ctx.context.secret_config, "better-auth-account", expires_in: attributes[:max_age])
133
135
  store = SessionStore.new(cookie.name, attributes, ctx)
134
136
 
135
137
  if value.length > SessionStore::CHUNK_SIZE
@@ -145,7 +147,7 @@ module BetterAuth
145
147
  value = SessionStore.get_chunked_cookie(ctx, cookie.name)
146
148
  return nil unless value
147
149
 
148
- Crypto.symmetric_decode_jwt(value, ctx.context.secret, "better-auth-account")
150
+ Crypto.symmetric_decode_jwt(value, ctx.context.secret_config, "better-auth-account")
149
151
  end
150
152
 
151
153
  def get_cookie_cache(request_or_cookie_header, secret:, strategy: "compact", version: nil, cookie_prefix: "better-auth", cookie_name: "session_data", is_secure: nil)
@@ -32,7 +32,9 @@ module BetterAuth
32
32
  delete_user: Routes.delete_user,
33
33
  delete_user_callback: Routes.delete_user_callback,
34
34
  list_accounts: Routes.list_accounts,
35
+ list_user_accounts: Routes.list_accounts,
35
36
  link_social: Routes.link_social,
37
+ link_social_account: Routes.link_social,
36
38
  unlink_account: Routes.unlink_account,
37
39
  get_access_token: Routes.get_access_token,
38
40
  refresh_token: Routes.refresh_token,
@@ -26,7 +26,7 @@ module BetterAuth
26
26
  "exp" => Time.now.to_i + expires_in.to_i,
27
27
  "jti" => SecureRandom.uuid
28
28
  )
29
- key = encryption_key(secret, salt)
29
+ key = encryption_key(current_secret(secret), salt)
30
30
  ::JWE.encrypt(JSON.generate(claims), key, alg: ALG, enc: ENC, kid: thumbprint(key))
31
31
  end
32
32
 
@@ -35,12 +35,17 @@ module BetterAuth
35
35
 
36
36
  header = protected_header(token)
37
37
  return nil unless valid_header?(header)
38
- return nil unless header["kid"].nil? || header["kid"] == thumbprint(encryption_key(secret, salt))
39
38
 
40
- payload = JSON.parse(::JWE.decrypt(token.to_s, encryption_key(secret, salt)))
41
- return nil if expired?(payload)
39
+ decryption_keys(secret, salt, header["kid"]).each do |key|
40
+ payload = JSON.parse(::JWE.decrypt(token.to_s, key))
41
+ return nil if expired?(payload)
42
42
 
43
- payload
43
+ return payload
44
+ rescue JSON::ParserError, ::JWE::DecodeError, ::JWE::InvalidData, ::JWE::BadCEK
45
+ next
46
+ end
47
+
48
+ nil
44
49
  rescue JSON::ParserError, ArgumentError, ::JWE::DecodeError, ::JWE::InvalidData, ::JWE::BadCEK
45
50
  nil
46
51
  end
@@ -57,6 +62,23 @@ module BetterAuth
57
62
  Crypto.base64url_encode(OpenSSL::Digest.digest("SHA256", JSON.generate(jwk)))
58
63
  end
59
64
 
65
+ def current_secret(secret)
66
+ secret.is_a?(SecretConfig) ? secret.current_secret : secret
67
+ end
68
+
69
+ def all_secrets(secret)
70
+ return [[0, secret]] unless secret.is_a?(SecretConfig)
71
+
72
+ secret.all_secrets
73
+ end
74
+
75
+ def decryption_keys(secret, salt, kid)
76
+ keys = all_secrets(secret).map { |_version, value| encryption_key(value, salt) }
77
+ return keys if kid.nil?
78
+
79
+ keys.select { |key| thumbprint(key) == kid }
80
+ end
81
+
60
82
  def protected_header(token)
61
83
  first_segment = token.to_s.split(".", 2).first
62
84
  JSON.parse(Crypto.base64url_decode(first_segment))
@@ -73,6 +73,11 @@ module BetterAuth
73
73
  end
74
74
 
75
75
  def symmetric_encrypt(key:, data:)
76
+ if key.is_a?(SecretConfig)
77
+ ciphertext = symmetric_encrypt(key: key.current_secret, data: data)
78
+ return "#{SecretConfig::ENVELOPE_PREFIX}#{key.current_version}$#{ciphertext}"
79
+ end
80
+
76
81
  cipher = OpenSSL::Cipher.new("aes-256-gcm")
77
82
  cipher.encrypt
78
83
  cipher.key = OpenSSL::Digest.digest("SHA256", key.to_s)
@@ -88,6 +93,20 @@ module BetterAuth
88
93
  end
89
94
 
90
95
  def symmetric_decrypt(key:, data:)
96
+ if key.is_a?(SecretConfig)
97
+ envelope = parse_envelope(data)
98
+ if envelope
99
+ secret = key.keys[envelope[:version]]
100
+ return nil unless secret
101
+
102
+ return symmetric_decrypt(key: secret, data: envelope[:ciphertext])
103
+ end
104
+
105
+ return nil unless key.legacy_secret
106
+
107
+ return symmetric_decrypt(key: key.legacy_secret, data: data)
108
+ end
109
+
91
110
  payload = JSON.parse(base64url_decode(data.to_s))
92
111
  cipher = OpenSSL::Cipher.new("aes-256-gcm")
93
112
  cipher.decrypt
@@ -137,6 +156,19 @@ module BetterAuth
137
156
  value
138
157
  end
139
158
 
159
+ def parse_envelope(data)
160
+ value = data.to_s
161
+ return nil unless value.start_with?(SecretConfig::ENVELOPE_PREFIX)
162
+
163
+ rest = value.delete_prefix(SecretConfig::ENVELOPE_PREFIX)
164
+ version, ciphertext = rest.split("$", 2)
165
+ return nil if version.to_s.empty? || ciphertext.to_s.empty?
166
+
167
+ {version: SecretConfig.parse_version!(version, source: "encrypted envelope"), ciphertext: ciphertext}
168
+ rescue Error
169
+ nil
170
+ end
171
+
140
172
  def keccak256_bytes(input)
141
173
  rate = 136
142
174
  state = Array.new(25, 0)
@@ -12,7 +12,7 @@ module BetterAuth
12
12
  def create(data, model, custom: nil, context: nil)
13
13
  run_before(model, :create, data, context) do |actual_data|
14
14
  created = custom ? custom.call(actual_data) : adapter.create(model: model, data: actual_data, force_allow_id: true)
15
- run_after(model, :create, created)
15
+ run_after(model, :create, created, context)
16
16
  created
17
17
  end
18
18
  end
@@ -20,7 +20,7 @@ module BetterAuth
20
20
  def update(data, where, model, custom: nil, context: nil)
21
21
  run_before(model, :update, data, context) do |actual_data|
22
22
  updated = custom ? custom.call(actual_data) : adapter.update(model: model, where: where, update: actual_data)
23
- run_after(model, :update, updated) if updated
23
+ run_after(model, :update, updated, context) if updated
24
24
  updated
25
25
  end
26
26
  end
@@ -28,7 +28,7 @@ module BetterAuth
28
28
  def update_many(data, where, model, custom: nil, context: nil)
29
29
  run_before(model, :update, data, context) do |actual_data|
30
30
  updated = custom ? custom.call(actual_data) : adapter.update_many(model: model, where: where, update: actual_data)
31
- run_after(model, :update, updated) if updated
31
+ run_after(model, :update, updated, context) if updated
32
32
  updated
33
33
  end
34
34
  end
@@ -68,8 +68,8 @@ module BetterAuth
68
68
  yield actual_data
69
69
  end
70
70
 
71
- def run_after(model, action, data)
72
- after_hooks(model, action).each { |hook| hook.call(data, nil) }
71
+ def run_after(model, action, data, context)
72
+ after_hooks(model, action).each { |hook| hook.call(data, context) }
73
73
  end
74
74
 
75
75
  def before_hooks(model, action)
@@ -10,6 +10,7 @@ module BetterAuth
10
10
  :params_schema,
11
11
  :headers_schema,
12
12
  :metadata,
13
+ :options,
13
14
  :use,
14
15
  :handler
15
16
 
@@ -21,6 +22,9 @@ module BetterAuth
21
22
  @params_schema = params_schema
22
23
  @headers_schema = headers_schema
23
24
  @metadata = metadata || {}
25
+ apply_default_open_api_metadata!
26
+ apply_open_api_schemas!
27
+ @options = endpoint_options
24
28
  @use = Array(use)
25
29
  @handler = handler || ->(_ctx) {}
26
30
  end
@@ -46,6 +50,34 @@ module BetterAuth
46
50
 
47
51
  private
48
52
 
53
+ def endpoint_options
54
+ {
55
+ method: (methods.length == 1) ? methods.first : methods,
56
+ body: body_schema,
57
+ query: query_schema,
58
+ params: params_schema,
59
+ headers: headers_schema,
60
+ metadata: metadata
61
+ }.compact
62
+ end
63
+
64
+ def apply_default_open_api_metadata!
65
+ return unless path
66
+ return if metadata[:openapi] || metadata[:hide] || metadata[:SERVER_ONLY] || metadata[:server_only]
67
+ return unless defined?(BetterAuth::OpenAPI)
68
+
69
+ metadata[:openapi] = BetterAuth::OpenAPI.default_metadata(path, methods)
70
+ end
71
+
72
+ def apply_open_api_schemas!
73
+ openapi = fetch_key(metadata, :openapi)
74
+ return unless openapi.is_a?(Hash)
75
+
76
+ @body_schema ||= schema_for_open_api_request_body(openapi)
77
+ @query_schema ||= schema_for_open_api_parameters(openapi, "query")
78
+ @headers_schema ||= schema_for_open_api_parameters(openapi, "header")
79
+ end
80
+
49
81
  def apply_schemas!(context)
50
82
  context.body = validate_schema(:body, body_schema, context.body)
51
83
  context.query = validate_schema(:query, query_schema, context.query)
@@ -86,6 +118,54 @@ module BetterAuth
86
118
  result
87
119
  end
88
120
 
121
+ def schema_for_open_api_request_body(openapi)
122
+ schema = fetch_key(fetch_key(fetch_key(fetch_key(openapi, :requestBody), :content), "application/json"), :schema)
123
+ required = Array(fetch_key(schema, :required)).map(&:to_s)
124
+ return nil if required.empty?
125
+
126
+ ->(value) { validate_required_open_api_fields(value, required) }
127
+ end
128
+
129
+ def schema_for_open_api_parameters(openapi, location)
130
+ required = Array(fetch_key(openapi, :parameters))
131
+ .select { |parameter| parameter.is_a?(Hash) && fetch_key(parameter, :in).to_s == location && fetch_key(parameter, :required) == true }
132
+ .filter_map { |parameter| fetch_key(parameter, :name) }
133
+ .map(&:to_s)
134
+ return nil if required.empty?
135
+
136
+ ->(value) { validate_required_open_api_fields(value, required) }
137
+ end
138
+
139
+ def validate_required_open_api_fields(value, required)
140
+ data = normalize_open_api_input(value)
141
+ return false unless required.all? { |key| data.key?(open_api_storage_key(key)) && !data[open_api_storage_key(key)].nil? }
142
+
143
+ value
144
+ end
145
+
146
+ def normalize_open_api_input(value)
147
+ return {} unless value.is_a?(Hash)
148
+
149
+ value.each_with_object({}) do |(key, object_value), result|
150
+ result[open_api_storage_key(key)] = object_value
151
+ end
152
+ end
153
+
154
+ def open_api_storage_key(key)
155
+ key.to_s
156
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
157
+ .tr("-", "_")
158
+ .downcase
159
+ .split("_")
160
+ .then { |parts| ([parts.first] + parts.drop(1).map(&:capitalize)).join }
161
+ end
162
+
163
+ def fetch_key(hash, key)
164
+ return nil unless hash.respond_to?(:[])
165
+
166
+ hash[key] || hash[key.to_s]
167
+ end
168
+
89
169
  class Result
90
170
  attr_accessor :response, :status, :headers
91
171
 
@@ -100,7 +180,7 @@ module BetterAuth
100
180
  return value if value.is_a?(self)
101
181
 
102
182
  if value.is_a?(APIError)
103
- return new(response: value, status: value.status_code, headers: value.headers)
183
+ return new(response: value, status: value.status_code, headers: merge_headers(context.response_headers, value.headers))
104
184
  end
105
185
 
106
186
  if rack_response?(value)
@@ -136,7 +216,11 @@ module BetterAuth
136
216
  end
137
217
 
138
218
  def to_rack_response
139
- return @raw_response if raw_response?
219
+ to_response.to_a
220
+ end
221
+
222
+ def to_response
223
+ return Response.from_rack(@raw_response) if raw_response?
140
224
 
141
225
  body = if response.nil?
142
226
  [JSON.generate(nil)]
@@ -146,7 +230,7 @@ module BetterAuth
146
230
  [JSON.generate(response)]
147
231
  end
148
232
  response_headers = {"content-type" => "application/json"}.merge(headers)
149
- [status, response_headers, body]
233
+ Response.new(status: status, headers: response_headers, body: body)
150
234
  end
151
235
 
152
236
  private
@@ -16,6 +16,7 @@ module BetterAuth
16
16
  "SOCIAL_ACCOUNT_ALREADY_LINKED" => "Social account already linked",
17
17
  "PROVIDER_NOT_FOUND" => "Provider not found",
18
18
  "INVALID_TOKEN" => "Invalid token",
19
+ "TOKEN_EXPIRED" => "Token expired",
19
20
  "ID_TOKEN_NOT_SUPPORTED" => "id_token not supported",
20
21
  "FAILED_TO_GET_USER_INFO" => "Failed to get user info",
21
22
  "USER_EMAIL_NOT_FOUND" => "User email not found",
@@ -47,6 +48,12 @@ module BetterAuth
47
48
  "FIELD_NOT_ALLOWED" => "Field not allowed to be set",
48
49
  "ASYNC_VALIDATION_NOT_SUPPORTED" => "Async validation is not supported",
49
50
  "VALIDATION_ERROR" => "Validation Error",
50
- "MISSING_FIELD" => "Field is required"
51
+ "MISSING_FIELD" => "Field is required",
52
+ "BODY_MUST_BE_AN_OBJECT" => "Body must be an object",
53
+ "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED" => "POST method requires deferSessionRefresh to be enabled in session config",
54
+ "PASSWORD_ALREADY_SET" => "User already has a password set",
55
+ "RESET_PASSWORD_DISABLED" => "Reset password isn't enabled",
56
+ "EMAIL_PASSWORD_DISABLED" => "Email and password is not enabled",
57
+ "EMAIL_PASSWORD_SIGN_UP_DISABLED" => "Email and password sign up is not enabled"
51
58
  }.freeze
52
59
  end
@@ -11,8 +11,8 @@ module BetterAuth
11
11
  fields: {
12
12
  role: {type: "string", required: false, input: false},
13
13
  banned: {type: "boolean", required: false, input: false, default_value: false},
14
- banReason: {type: "string", required: false, input: false},
15
- banExpires: {type: "date", required: false, input: false}
14
+ banReason: {type: "string", required: false, input: false, default_value: nil},
15
+ banExpires: {type: "date", required: false, input: false, default_value: nil}
16
16
  }
17
17
  },
18
18
  session: {