better_auth 0.10.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +138 -91
  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 +191 -56
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +294 -49
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +41 -46
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +110 -14
  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 +14 -3
  18. data/lib/better_auth/error.rb +3 -1
  19. data/lib/better_auth/logger.rb +1 -1
  20. data/lib/better_auth/middleware/origin_check.rb +9 -1
  21. data/lib/better_auth/migration_plan.rb +4 -4
  22. data/lib/better_auth/oauth_state.rb +147 -0
  23. data/lib/better_auth/plugin_loader.rb +220 -0
  24. data/lib/better_auth/plugins/access.rb +10 -13
  25. data/lib/better_auth/plugins/admin.rb +79 -28
  26. data/lib/better_auth/plugins/api_key.rb +4 -7
  27. data/lib/better_auth/plugins/bearer.rb +8 -22
  28. data/lib/better_auth/plugins/captcha.rb +24 -6
  29. data/lib/better_auth/plugins/device_authorization.rb +47 -9
  30. data/lib/better_auth/plugins/dub.rb +2 -2
  31. data/lib/better_auth/plugins/email_otp.rb +30 -47
  32. data/lib/better_auth/plugins/generic_oauth.rb +141 -146
  33. data/lib/better_auth/plugins/i18n.rb +215 -0
  34. data/lib/better_auth/plugins/jwt.rb +37 -19
  35. data/lib/better_auth/plugins/magic_link.rb +18 -21
  36. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  37. data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
  38. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  39. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  40. data/lib/better_auth/plugins/one_tap.rb +61 -60
  41. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  42. data/lib/better_auth/plugins/open_api.rb +35 -7
  43. data/lib/better_auth/plugins/organization.rb +455 -122
  44. data/lib/better_auth/plugins/passkey.rb +4 -7
  45. data/lib/better_auth/plugins/phone_number.rb +24 -26
  46. data/lib/better_auth/plugins/scim.rb +4 -7
  47. data/lib/better_auth/plugins/siwe.rb +126 -16
  48. data/lib/better_auth/plugins/sso.rb +4 -7
  49. data/lib/better_auth/plugins/stripe.rb +4 -7
  50. data/lib/better_auth/plugins/two_factor.rb +186 -21
  51. data/lib/better_auth/plugins/username.rb +56 -13
  52. data/lib/better_auth/plugins.rb +57 -0
  53. data/lib/better_auth/rate_limiter.rb +297 -135
  54. data/lib/better_auth/request_ip.rb +123 -13
  55. data/lib/better_auth/router.rb +3 -3
  56. data/lib/better_auth/routes/account.rb +40 -8
  57. data/lib/better_auth/routes/email_verification.rb +88 -5
  58. data/lib/better_auth/routes/password.rb +5 -6
  59. data/lib/better_auth/routes/session.rb +7 -2
  60. data/lib/better_auth/routes/sign_in.rb +10 -1
  61. data/lib/better_auth/routes/sign_up.rb +45 -22
  62. data/lib/better_auth/routes/social.rb +169 -54
  63. data/lib/better_auth/routes/user.rb +11 -8
  64. data/lib/better_auth/schema/sql.rb +90 -24
  65. data/lib/better_auth/schema.rb +124 -3
  66. data/lib/better_auth/session.rb +27 -9
  67. data/lib/better_auth/session_store.rb +1 -1
  68. data/lib/better_auth/social_providers/apple.rb +6 -1
  69. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  70. data/lib/better_auth/social_providers/base.rb +41 -14
  71. data/lib/better_auth/social_providers/cognito.rb +75 -5
  72. data/lib/better_auth/social_providers/facebook.rb +95 -1
  73. data/lib/better_auth/social_providers/figma.rb +2 -0
  74. data/lib/better_auth/social_providers/google.rb +12 -1
  75. data/lib/better_auth/social_providers/line.rb +35 -1
  76. data/lib/better_auth/social_providers/linear.rb +1 -0
  77. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  78. data/lib/better_auth/social_providers/naver.rb +24 -1
  79. data/lib/better_auth/social_providers/notion.rb +1 -0
  80. data/lib/better_auth/social_providers/paybin.rb +25 -1
  81. data/lib/better_auth/social_providers/paypal.rb +118 -2
  82. data/lib/better_auth/social_providers/reddit.rb +25 -4
  83. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  84. data/lib/better_auth/social_providers/twitch.rb +24 -1
  85. data/lib/better_auth/social_providers/twitter.rb +36 -1
  86. data/lib/better_auth/social_providers/vk.rb +34 -1
  87. data/lib/better_auth/social_providers/wechat.rb +6 -4
  88. data/lib/better_auth/sql_migration.rb +5 -5
  89. data/lib/better_auth/version.rb +1 -1
  90. data/lib/better_auth.rb +11 -41
  91. metadata +5 -13
  92. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  93. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  94. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  95. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  96. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  97. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  98. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  99. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  100. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  101. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  102. data/lib/better_auth/plugins/mcp.rb +0 -256
  103. data/lib/better_auth/plugins/oidc_provider.rb +0 -769
@@ -18,7 +18,8 @@ module BetterAuth
18
18
 
19
19
  def initialize(configuration)
20
20
  @app_name = configuration.app_name
21
- @base_url = configuration.context_base_url
21
+ @canonical_base_url = configuration.context_base_url
22
+ @base_url = @canonical_base_url
22
23
  @version = BetterAuth::VERSION
23
24
  @options = configuration
24
25
  @social_providers = configuration.social_providers
@@ -30,6 +31,7 @@ module BetterAuth
30
31
  @session_config = configuration.session
31
32
  @rate_limit_config = configuration.rate_limit
32
33
  @trusted_origins = configuration.trusted_origins
34
+ @explicit_trusted_origins = configuration.explicit_trusted_origins
33
35
  @secret = configuration.secret
34
36
  @secret_config = configuration.secret_config
35
37
  @current_session = nil
@@ -46,10 +48,20 @@ module BetterAuth
46
48
  runtime_fetch(:base_url, @base_url)
47
49
  end
48
50
 
51
+ def token_link_base_url
52
+ base_url
53
+ end
54
+
55
+ attr_reader :canonical_base_url
56
+
49
57
  def trusted_origins
50
58
  runtime_fetch(:trusted_origins, @trusted_origins)
51
59
  end
52
60
 
61
+ def explicit_trusted_origins
62
+ runtime_fetch(:explicit_trusted_origins, @explicit_trusted_origins)
63
+ end
64
+
53
65
  def auth_cookies
54
66
  runtime_fetch(:auth_cookies, @auth_cookies)
55
67
  end
@@ -131,6 +143,7 @@ module BetterAuth
131
143
  @session_config = options.session
132
144
  @rate_limit_config = options.rate_limit
133
145
  @trusted_origins = options.trusted_origins
146
+ @explicit_trusted_origins = options.explicit_trusted_origins
134
147
  @secret = options.secret
135
148
  @secret_config = options.secret_config
136
149
  end
@@ -150,12 +163,7 @@ module BetterAuth
150
163
  runtime = request_runtime
151
164
  runtime[:current_session] = nil
152
165
  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
166
+ runtime[:base_url] = serving_base_url(request)
159
167
  runtime[:trusted_origins] = current_trusted_origins(request)
160
168
  end
161
169
 
@@ -163,61 +171,30 @@ module BetterAuth
163
171
  runtime = request_runtime
164
172
  runtime[:current_session] = nil
165
173
  runtime[:new_session] = nil
166
- if options.dynamic_base_url?
167
- runtime[:base_url] = resolved_dynamic_base_url(source)
168
- refresh_cookies!
169
- end
174
+ runtime[:base_url] = serving_base_url(source)
170
175
  runtime[:trusted_origins] = current_trusted_origins(request_for_callbacks(source))
171
176
  end
172
177
 
173
178
  def reset_runtime!
174
179
  Thread.current[runtime_key] = nil if request_runtime?
175
- options.clear_runtime_base_url! if options.respond_to?(:clear_runtime_base_url!)
176
180
  @current_session = nil
177
181
  @new_session = nil
178
182
  end
179
183
 
180
184
  def clear_runtime!
181
185
  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
186
  end
195
187
 
196
188
  private
197
189
 
198
- def inferred_base_url(request)
199
- origin = inferred_origin(request)
200
- path = options.base_path
201
- path.empty? ? origin : "#{origin}#{path}"
202
- end
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)
190
+ def serving_base_url(source)
191
+ candidate = inferred_origin(source)
192
+ return canonical_base_url unless candidate
193
+ return canonical_base_url if candidate == options.base_url
194
+ return canonical_base_url unless options.serving_origins.any? { |pattern| Configuration.matches_origin_pattern?(candidate, pattern) }
219
195
 
220
- !!options.advanced[:trusted_proxy_headers]
196
+ path = options.base_path
197
+ path.empty? ? candidate : "#{candidate}#{path}"
221
198
  end
222
199
 
223
200
  def request_for_callbacks(source)
@@ -230,6 +207,7 @@ module BetterAuth
230
207
  Thread.current[runtime_key] ||= {
231
208
  base_url: @base_url,
232
209
  trusted_origins: @trusted_origins,
210
+ explicit_trusted_origins: @explicit_trusted_origins,
233
211
  auth_cookies: @auth_cookies,
234
212
  cookies: @cookies,
235
213
  current_session: nil,
@@ -256,26 +234,25 @@ module BetterAuth
256
234
  :"better_auth_context_runtime_#{object_id}"
257
235
  end
258
236
 
259
- def inferred_origin(request)
260
- forwarded_host = request.get_header("HTTP_X_FORWARDED_HOST")
261
- forwarded_proto = request.get_header("HTTP_X_FORWARDED_PROTO")
262
- if options.advanced[:trusted_proxy_headers] && valid_forwarded?(forwarded_host, forwarded_proto)
237
+ def inferred_origin(source)
238
+ return nil unless source
239
+
240
+ trusted_proxy_headers = options.advanced[:trusted_proxy_headers] == true
241
+ forwarded_host = URLHelpers.header_value(URLHelpers.headers_from_source(source), "x-forwarded-host")
242
+ if trusted_proxy_headers && forwarded_host
243
+ forwarded_proto = URLHelpers.header_value(URLHelpers.headers_from_source(source), "x-forwarded-proto")
244
+ return nil unless valid_proxy_host?(forwarded_host.to_s) && valid_proxy_proto?(forwarded_proto.to_s)
245
+
263
246
  return "#{forwarded_proto}://#{forwarded_host}"
264
247
  end
265
248
 
266
- scheme = request.get_header("rack.url_scheme") || request.scheme
267
- scheme = "https" unless valid_proxy_proto?(scheme.to_s)
268
- host_header = request.get_header("HTTP_HOST")
269
- return "#{scheme}://#{host_header}" if host_header && valid_proxy_host?(host_header.to_s)
249
+ host = URLHelpers.host_from_source(source, trusted_proxy_headers: false)
250
+ return nil unless host && valid_proxy_host?(host)
270
251
 
271
- host = request.get_header("SERVER_NAME") || request.host
272
- port = (request.get_header("SERVER_PORT") || request.port).to_i
273
- default_port = (scheme == "http" && port == 80) || (scheme == "https" && port == 443)
274
- default_port ? "#{scheme}://#{host}" : "#{scheme}://#{host}:#{port}"
275
- end
252
+ protocol = URLHelpers.protocol_from_source(source, trusted_proxy_headers: false)
253
+ return nil unless valid_proxy_proto?(protocol)
276
254
 
277
- def valid_forwarded?(host, proto)
278
- valid_proxy_proto?(proto.to_s) && valid_proxy_host?(host.to_s)
255
+ "#{protocol}://#{host}"
279
256
  end
280
257
 
281
258
  def valid_proxy_proto?(proto)
@@ -315,16 +292,15 @@ module BetterAuth
315
292
  end
316
293
 
317
294
  def current_trusted_origins(request)
318
- origins = []
319
- origins << Configuration.origin_for(URI.parse(base_url)) unless base_url.to_s.empty?
320
- origins.concat(options.trusted_origins)
295
+ origins = options.trusted_origins.dup
296
+ dynamic_origins = []
321
297
  if options.trusted_origins_callback
322
- origins.concat(Array(options.trusted_origins_callback.call(request)).compact)
298
+ dynamic_origins.concat(Array(options.trusted_origins_callback.call(request)).compact)
323
299
  end
300
+ origins.concat(dynamic_origins)
324
301
  origins.concat(Env.csv("BETTER_AUTH_TRUSTED_ORIGINS"))
302
+ runtime_store(:explicit_trusted_origins, (options.explicit_trusted_origins + dynamic_origins.map(&:to_s)).uniq)
325
303
  origins.map(&:to_s).reject(&:empty?).uniq
326
- rescue URI::InvalidURIError
327
- options.trusted_origins
328
304
  end
329
305
 
330
306
  def normalize_context(value)
@@ -8,8 +8,10 @@ module BetterAuth
8
8
  module Cookies
9
9
  SECURE_COOKIE_PREFIX = "__Secure-"
10
10
  HOST_COOKIE_PREFIX = "__Host-"
11
+ COOKIE_NAME_PATTERN = /\A[\x21\x23-\x27\x2A\x2B\x2D\x2E\x30-\x39\x41-\x5A\x5E\x5F\x60\x61-\x7A\x7C\x7E]+\z/
12
+ COOKIE_VALUE_PATTERN = /\A[\x20\x21\x23-\x3A\x3C-\x5B\x5D-\x7E]*\z/
11
13
 
12
- Cookie = Struct.new(:name, :attributes, keyword_init: true) do
14
+ Cookie = Struct.new(:name, :attributes) do
13
15
  alias_method :options, :attributes
14
16
  end
15
17
 
@@ -40,8 +42,6 @@ module BetterAuth
40
42
  uri.host unless uri.host.to_s.empty?
41
43
  end
42
44
  end
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
-
45
45
  custom = advanced.dig(:cookies, cookie_name.to_sym) || {}
46
46
  prefix = advanced[:cookie_prefix] || "better-auth"
47
47
  name = custom[:name] || "#{prefix}.#{cookie_name}"
@@ -63,14 +63,51 @@ module BetterAuth
63
63
  end
64
64
 
65
65
  def parse_cookies(cookie_header)
66
- cookie_header.to_s.split(/;\s*/).each_with_object({}) do |pair, result|
67
- name, value = pair.split("=", 2)
68
- next if name.to_s.empty? || value.nil?
66
+ header = cookie_header.to_s
67
+ return {} if header.length < 2
68
+
69
+ header.split(";", -1).each_with_object({}) do |pair, result|
70
+ separator = pair.index("=")
71
+ next unless separator
72
+
73
+ name = trim_cookie_ows(pair[0...separator])
74
+ value = unquote_cookie_value(trim_cookie_ows(pair[(separator + 1)..]))
75
+ next unless COOKIE_NAME_PATTERN.match?(name) && COOKIE_VALUE_PATTERN.match?(value)
69
76
 
70
- result[name.strip] = decode_cookie_value(value.strip)
77
+ result[name] = decode_cookie_value(value)
71
78
  end
72
79
  end
73
80
 
81
+ def split_set_cookie_header(header)
82
+ Array(header).flat_map do |value|
83
+ value.to_s.lines(chomp: true).flat_map do |line|
84
+ line.split(/,(?=[ \t]*[!#$%&'*+\-.^_`|~0-9A-Za-z]+=)/).map(&:strip)
85
+ end
86
+ end.reject(&:empty?)
87
+ end
88
+
89
+ def parse_set_cookie(line)
90
+ first, *attributes = line.to_s.split(";").map(&:strip)
91
+ name, value = first.split("=", 2)
92
+ return unless name && value
93
+
94
+ {
95
+ name: name,
96
+ value: value,
97
+ attributes: attributes.each_with_object({}) do |attribute, result|
98
+ key, attribute_value = attribute.split("=", 2)
99
+ result[key.to_s.downcase] = attribute_value || true unless key.to_s.empty?
100
+ end
101
+ }
102
+ end
103
+
104
+ def set_request_cookie(cookie_header, name, value)
105
+ cookies = parse_cookies(cookie_header)
106
+ cookie_name = name.to_s
107
+ cookies[cookie_name] = value.to_s if COOKIE_NAME_PATTERN.match?(cookie_name)
108
+ cookies.map { |key, semantic_value| "#{key}=#{encode_cookie_value(semantic_value)}" }.join("; ")
109
+ end
110
+
74
111
  def strip_secure_cookie_prefix(name)
75
112
  name.to_s.delete_prefix(SECURE_COOKIE_PREFIX).delete_prefix(HOST_COOKIE_PREFIX)
76
113
  end
@@ -83,10 +120,10 @@ module BetterAuth
83
120
  cookie_name = config[:cookie_name] || "session_token"
84
121
  cookie_prefix = config[:cookie_prefix] || "better-auth"
85
122
  candidates = [
86
- "#{cookie_prefix}.#{cookie_name}",
87
123
  "#{SECURE_COOKIE_PREFIX}#{cookie_prefix}.#{cookie_name}",
88
- "#{cookie_prefix}-#{cookie_name}",
89
- "#{SECURE_COOKIE_PREFIX}#{cookie_prefix}-#{cookie_name}"
124
+ "#{cookie_prefix}.#{cookie_name}",
125
+ "#{SECURE_COOKIE_PREFIX}#{cookie_prefix}-#{cookie_name}",
126
+ "#{cookie_prefix}-#{cookie_name}"
90
127
  ]
91
128
  candidates.lazy.filter_map { |candidate| parsed[candidate] }.first
92
129
  end
@@ -101,16 +138,23 @@ module BetterAuth
101
138
  ctx.set_signed_cookie(dont_remember_cookie.name, "true", ctx.context.secret, dont_remember_cookie.attributes)
102
139
  end
103
140
 
104
- set_cookie_cache(ctx, session, dont_remember_me)
141
+ set_cookie_cache(ctx, session, dont_remember_me, max_age: overrides&.fetch(:max_age, nil))
105
142
  ctx.context.set_new_session(session) if ctx.context.respond_to?(:set_new_session)
106
143
  end
107
144
 
108
- def set_cookie_cache(ctx, session, dont_remember_me)
145
+ def set_cookie_cache(ctx, session, dont_remember_me, max_age: nil)
109
146
  config = ctx.context.session_config[:cookie_cache] || {}
110
147
  return unless config[:enabled]
111
148
 
112
149
  cookie = ctx.context.auth_cookies[:session_data]
113
- max_age = dont_remember_me ? nil : cookie.attributes[:max_age]
150
+ configured_max_age = cookie.attributes[:max_age]
151
+ max_age = if dont_remember_me
152
+ nil
153
+ elsif max_age
154
+ [configured_max_age, max_age].compact.map(&:to_i).min
155
+ else
156
+ configured_max_age
157
+ end
114
158
  data = filtered_cache_data(ctx, session)
115
159
  strategy = config[:strategy] || "compact"
116
160
  secret = (strategy.to_s == "jwe") ? ctx.context.secret_config : ctx.context.secret
@@ -168,6 +212,7 @@ module BetterAuth
168
212
 
169
213
  payload = decode_cookie_cache(raw, secret, strategy: strategy)
170
214
  return nil unless payload && payload["session"] && payload["user"]
215
+ return nil if embedded_session_expired?(payload["session"])
171
216
 
172
217
  expected_version = cookie_cache_version(version, payload["session"], payload["user"])
173
218
  return nil if version && (payload["version"] || "1") != expected_version
@@ -176,6 +221,7 @@ module BetterAuth
176
221
  end
177
222
 
178
223
  def expire_cookie(ctx, cookie)
224
+ remove_set_cookie_entries(ctx, cookie.name)
179
225
  ctx.set_cookie(cookie.name, "", cookie.attributes.merge(max_age: 0))
180
226
  end
181
227
 
@@ -184,6 +230,11 @@ module BetterAuth
184
230
  expire_cookie(ctx, ctx.context.auth_cookies[:session_data])
185
231
  expire_cookie(ctx, ctx.context.auth_cookies[:account_data]) if ctx.context.options.account[:store_account_cookie]
186
232
 
233
+ if ctx.context.options.account[:store_account_cookie]
234
+ account_store = SessionStore.new(ctx.context.auth_cookies[:account_data].name, ctx.context.auth_cookies[:account_data].attributes, ctx)
235
+ account_store.set_cookies(account_store.clean)
236
+ end
237
+
187
238
  store = SessionStore.new(ctx.context.auth_cookies[:session_data].name, ctx.context.auth_cookies[:session_data].attributes, ctx)
188
239
  store.set_cookies(store.clean)
189
240
  expire_cookie(ctx, ctx.context.auth_cookies[:dont_remember]) unless skip_dont_remember_me
@@ -250,12 +301,57 @@ module BetterAuth
250
301
  chunks.sort_by(&:first).map(&:last).join
251
302
  end
252
303
 
304
+ def embedded_session_expired?(session)
305
+ expires_at = session["expiresAt"] || session[:expiresAt] || session["expires_at"] || session[:expires_at]
306
+ normalized = Session.normalize_time(expires_at)
307
+ normalized && normalized <= Time.now
308
+ rescue ArgumentError
309
+ true
310
+ end
311
+
312
+ def remove_set_cookie_entries(ctx, cookie_name)
313
+ header = ctx.response_headers["set-cookie"]
314
+ return unless header
315
+
316
+ exact = "#{cookie_name}="
317
+ chunk = "#{cookie_name}."
318
+ survivors = header.to_s.lines(chomp: true).reject do |entry|
319
+ entry.start_with?(exact, chunk)
320
+ end
321
+ if survivors.empty?
322
+ ctx.response_headers.delete("set-cookie")
323
+ else
324
+ ctx.response_headers["set-cookie"] = survivors.join("\n")
325
+ end
326
+ end
327
+
253
328
  def decode_cookie_value(value)
254
- URI.decode_uri_component(value)
329
+ decoded = URI.decode_uri_component(value)
330
+ decoded.valid_encoding? ? decoded : value
255
331
  rescue ArgumentError
256
332
  value
257
333
  end
258
334
 
335
+ def trim_cookie_ows(value)
336
+ value.sub(/\A[ \t]*/, "").sub(/[ \t]*\z/, "")
337
+ end
338
+
339
+ def unquote_cookie_value(value)
340
+ return value unless value.length >= 2 && value.start_with?("\"") && value.end_with?("\"")
341
+
342
+ value[1...-1]
343
+ end
344
+
345
+ def encode_cookie_value(value)
346
+ value.to_s.encode(Encoding::UTF_8).bytes.map do |byte|
347
+ if byte.between?(48, 57) || byte.between?(65, 90) || byte.between?(97, 122) || [33, 39, 40, 41, 42, 45, 46, 95, 126].include?(byte)
348
+ byte.chr
349
+ else
350
+ "%#{byte.to_s(16).upcase.rjust(2, "0")}"
351
+ end
352
+ end.join
353
+ end
354
+
259
355
  def header_value(request_or_cookie_header)
260
356
  return request_or_cookie_header.headers["cookie"] if request_or_cookie_header.respond_to?(:headers)
261
357
  return request_or_cookie_header.get_header("HTTP_COOKIE") if request_or_cookie_header.respond_to?(:get_header)
@@ -32,9 +32,7 @@ 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,
36
35
  link_social: Routes.link_social,
37
- link_social_account: Routes.link_social,
38
36
  unlink_account: Routes.unlink_account,
39
37
  get_access_token: Routes.get_access_token,
40
38
  refresh_token: Routes.refresh_token,
@@ -9,7 +9,8 @@ require_relative "crypto/jwe"
9
9
 
10
10
  module BetterAuth
11
11
  module Crypto
12
- URL_SAFE_ALPHABET = [*"a".."z", *"A".."Z", *"0".."9", "-", "_"].freeze
12
+ ALPHABETIC_ALPHABET = [*"a".."z", *"A".."Z"].freeze
13
+ URL_SAFE_ALPHABET = [*ALPHABETIC_ALPHABET, *"0".."9", "-", "_"].freeze
13
14
  MASK_64 = (1 << 64) - 1
14
15
  KECCAK_ROUND_CONSTANTS = [
15
16
  0x0000000000000001, 0x0000000000008082, 0x800000000000808a, 0x8000000080008000,
@@ -29,8 +30,8 @@ module BetterAuth
29
30
 
30
31
  module_function
31
32
 
32
- def random_string(length = 32)
33
- Array.new(length) { URL_SAFE_ALPHABET[SecureRandom.random_number(URL_SAFE_ALPHABET.length)] }.join
33
+ def random_string(length = 32, alphabet: URL_SAFE_ALPHABET)
34
+ Array.new(length) { alphabet[SecureRandom.random_number(alphabet.length)] }.join
34
35
  end
35
36
 
36
37
  def uuid
@@ -4,7 +4,7 @@ require "better_auth/sql_migration"
4
4
 
5
5
  module BetterAuth
6
6
  module Doctor
7
- Result = Struct.new(:ok, :warnings, :errors, keyword_init: true) do
7
+ Result = Struct.new(:ok, :warnings, :errors) do
8
8
  def success?
9
9
  errors.empty?
10
10
  end
@@ -31,6 +31,15 @@ module BetterAuth
31
31
  result.success? ? 0 : 1
32
32
  end
33
33
 
34
+ def as_json(result)
35
+ {
36
+ "ok" => result.ok,
37
+ "warnings" => result.warnings,
38
+ "errors" => result.errors,
39
+ "success" => result.success?
40
+ }
41
+ end
42
+
34
43
  def check_secret(config, result)
35
44
  secret = config.secret.to_s
36
45
  if secret.empty?
@@ -49,7 +58,7 @@ module BetterAuth
49
58
  def check_base_url(config, result)
50
59
  base_url = config.base_url.to_s
51
60
  if base_url.empty?
52
- result.warnings << "base_url is not configured; set it explicitly in production"
61
+ result.errors << "base_url is not configured"
53
62
  elsif !base_url.start_with?("https://")
54
63
  result.warnings << "base_url is not HTTPS"
55
64
  else
@@ -71,7 +80,9 @@ module BetterAuth
71
80
  auth = BetterAuth.auth(config.to_h)
72
81
  adapter = auth.context.adapter
73
82
  unless adapter.respond_to?(:dialect) && adapter.respond_to?(:connection)
74
- result.warnings << "database adapter does not expose SQL migration introspection; schema drift check skipped"
83
+ warning = "database adapter does not expose SQL migration introspection; schema drift check skipped"
84
+ warning += "; run `better-auth mongo indexes` to ensure MongoDB indexes" if adapter.respond_to?(:ensure_indexes!)
85
+ result.warnings << warning
75
86
  return
76
87
  end
77
88
 
@@ -17,6 +17,7 @@ module BetterAuth
17
17
  "PROVIDER_NOT_FOUND" => "Provider not found",
18
18
  "INVALID_TOKEN" => "Invalid token",
19
19
  "TOKEN_EXPIRED" => "Token expired",
20
+ "TOKEN_ALREADY_USED" => "Token has already been used",
20
21
  "ID_TOKEN_NOT_SUPPORTED" => "id_token not supported",
21
22
  "FAILED_TO_GET_USER_INFO" => "Failed to get user info",
22
23
  "USER_EMAIL_NOT_FOUND" => "User email not found",
@@ -54,6 +55,7 @@ module BetterAuth
54
55
  "PASSWORD_ALREADY_SET" => "User already has a password set",
55
56
  "RESET_PASSWORD_DISABLED" => "Reset password isn't enabled",
56
57
  "EMAIL_PASSWORD_DISABLED" => "Email and password is not enabled",
57
- "EMAIL_PASSWORD_SIGN_UP_DISABLED" => "Email and password sign up is not enabled"
58
+ "EMAIL_PASSWORD_SIGN_UP_DISABLED" => "Email and password sign up is not enabled",
59
+ "CHANGE_EMAIL_DISABLED" => "Change email is disabled"
58
60
  }.freeze
59
61
  end
@@ -4,7 +4,7 @@ module BetterAuth
4
4
  module Logger
5
5
  LEVELS = [:debug, :info, :success, :warn, :error].freeze
6
6
 
7
- Internal = Struct.new(:level, :disabled, :handler, keyword_init: true) do
7
+ Internal = Struct.new(:level, :disabled, :handler) do
8
8
  LEVELS.each do |log_level|
9
9
  define_method(log_level) do |message, *args|
10
10
  return if disabled || !Logger.should_publish?(level, log_level)
@@ -55,7 +55,11 @@ module BetterAuth
55
55
  mode = headers["sec-fetch-mode"]
56
56
  dest = headers["sec-fetch-dest"]
57
57
  has_metadata = [site, mode, dest].any? { |value| value && !value.to_s.strip.empty? }
58
- return unless has_metadata
58
+ unless has_metadata
59
+ origin = headers["origin"] || headers["referer"]
60
+ validate_origin(endpoint_context, force: true) if login_or_signup_path?(endpoint_context.path) && origin && !origin.to_s.empty?
61
+ return
62
+ end
59
63
 
60
64
  if site == "cross-site" && mode == "navigate"
61
65
  log(endpoint_context.context, :error, "Blocked cross-site navigation login attempt (CSRF protection)")
@@ -109,6 +113,10 @@ module BetterAuth
109
113
  skip.any? { |path| endpoint_context.path.start_with?(path.to_s) }
110
114
  end
111
115
 
116
+ def login_or_signup_path?(path)
117
+ path.to_s.match?(%r{/(?:sign-in|sign-up)(?:/|$)})
118
+ end
119
+
112
120
  def fetch_data(data, key)
113
121
  return unless data.is_a?(Hash)
114
122
 
@@ -2,11 +2,11 @@
2
2
 
3
3
  module BetterAuth
4
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)
5
+ TableChange = Struct.new(:logical_name, :table_name, :table, :order)
6
+ FieldChange = Struct.new(:logical_name, :table_name, :fields, :table, :order)
7
+ IndexChange = Struct.new(:table_name, :field_name, :name, :unique, :field)
8
8
 
9
- Plan = Struct.new(:to_create, :to_add, :to_index, :warnings, :dialect, :tables, keyword_init: true) do
9
+ Plan = Struct.new(:to_create, :to_add, :to_index, :warnings, :dialect, :tables) do
10
10
  def empty?
11
11
  to_create.empty? && to_add.empty? && to_index.empty?
12
12
  end