better_auth 0.8.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +142 -95
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +303 -54
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +390 -63
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +48 -47
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +119 -15
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +108 -0
  18. data/lib/better_auth/endpoint.rb +88 -5
  19. data/lib/better_auth/error.rb +3 -1
  20. data/lib/better_auth/http_client.rb +46 -0
  21. data/lib/better_auth/logger.rb +1 -1
  22. data/lib/better_auth/middleware/origin_check.rb +9 -1
  23. data/lib/better_auth/migration_plan.rb +15 -0
  24. data/lib/better_auth/oauth2.rb +1 -1
  25. data/lib/better_auth/oauth_state.rb +147 -0
  26. data/lib/better_auth/plugin_loader.rb +220 -0
  27. data/lib/better_auth/plugins/access.rb +10 -13
  28. data/lib/better_auth/plugins/admin.rb +85 -29
  29. data/lib/better_auth/plugins/anonymous.rb +2 -0
  30. data/lib/better_auth/plugins/api_key.rb +4 -7
  31. data/lib/better_auth/plugins/bearer.rb +8 -22
  32. data/lib/better_auth/plugins/captcha.rb +24 -6
  33. data/lib/better_auth/plugins/device_authorization.rb +77 -5
  34. data/lib/better_auth/plugins/dub.rb +10 -2
  35. data/lib/better_auth/plugins/email_otp.rb +30 -47
  36. data/lib/better_auth/plugins/generic_oauth.rb +162 -140
  37. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  38. data/lib/better_auth/plugins/i18n.rb +215 -0
  39. data/lib/better_auth/plugins/jwt.rb +44 -19
  40. data/lib/better_auth/plugins/magic_link.rb +18 -21
  41. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  42. data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
  43. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  44. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  45. data/lib/better_auth/plugins/one_tap.rb +78 -65
  46. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  47. data/lib/better_auth/plugins/open_api.rb +77 -9
  48. data/lib/better_auth/plugins/organization.rb +546 -102
  49. data/lib/better_auth/plugins/passkey.rb +4 -7
  50. data/lib/better_auth/plugins/phone_number.rb +25 -27
  51. data/lib/better_auth/plugins/scim.rb +4 -7
  52. data/lib/better_auth/plugins/siwe.rb +126 -16
  53. data/lib/better_auth/plugins/sso.rb +4 -7
  54. data/lib/better_auth/plugins/stripe.rb +4 -7
  55. data/lib/better_auth/plugins/two_factor.rb +205 -19
  56. data/lib/better_auth/plugins/username.rb +56 -13
  57. data/lib/better_auth/plugins.rb +57 -0
  58. data/lib/better_auth/rate_limiter.rb +300 -133
  59. data/lib/better_auth/request_ip.rb +123 -13
  60. data/lib/better_auth/router.rb +3 -3
  61. data/lib/better_auth/routes/account.rb +44 -8
  62. data/lib/better_auth/routes/email_verification.rb +93 -6
  63. data/lib/better_auth/routes/password.rb +6 -6
  64. data/lib/better_auth/routes/session.rb +7 -2
  65. data/lib/better_auth/routes/sign_in.rb +10 -1
  66. data/lib/better_auth/routes/sign_up.rb +45 -22
  67. data/lib/better_auth/routes/social.rb +197 -54
  68. data/lib/better_auth/routes/user.rb +17 -10
  69. data/lib/better_auth/schema/sql.rb +189 -34
  70. data/lib/better_auth/schema.rb +159 -5
  71. data/lib/better_auth/session.rb +29 -10
  72. data/lib/better_auth/session_store.rb +1 -1
  73. data/lib/better_auth/social_providers/apple.rb +6 -1
  74. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  75. data/lib/better_auth/social_providers/base.rb +45 -23
  76. data/lib/better_auth/social_providers/cognito.rb +75 -5
  77. data/lib/better_auth/social_providers/facebook.rb +95 -1
  78. data/lib/better_auth/social_providers/figma.rb +2 -0
  79. data/lib/better_auth/social_providers/github.rb +2 -0
  80. data/lib/better_auth/social_providers/google.rb +12 -1
  81. data/lib/better_auth/social_providers/line.rb +35 -1
  82. data/lib/better_auth/social_providers/linear.rb +1 -0
  83. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  84. data/lib/better_auth/social_providers/naver.rb +24 -1
  85. data/lib/better_auth/social_providers/notion.rb +1 -0
  86. data/lib/better_auth/social_providers/paybin.rb +25 -1
  87. data/lib/better_auth/social_providers/paypal.rb +118 -2
  88. data/lib/better_auth/social_providers/reddit.rb +25 -4
  89. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  90. data/lib/better_auth/social_providers/twitch.rb +24 -1
  91. data/lib/better_auth/social_providers/twitter.rb +36 -1
  92. data/lib/better_auth/social_providers/vk.rb +34 -1
  93. data/lib/better_auth/social_providers/wechat.rb +6 -4
  94. data/lib/better_auth/sql_migration.rb +566 -0
  95. data/lib/better_auth/version.rb +1 -1
  96. data/lib/better_auth.rb +14 -41
  97. metadata +14 -18
  98. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  99. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  100. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  101. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  102. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  103. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  104. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  105. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  106. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  107. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  108. data/lib/better_auth/plugins/mcp.rb +0 -215
  109. data/lib/better_auth/plugins/oidc_provider.rb +0 -710
@@ -31,7 +31,7 @@ module BetterAuth
31
31
  }.freeze
32
32
 
33
33
  attr_reader :app_name,
34
- :base_url_config,
34
+ :base_url,
35
35
  :base_path,
36
36
  :context_base_url,
37
37
  :secret,
@@ -39,6 +39,7 @@ module BetterAuth
39
39
  :database,
40
40
  :plugins,
41
41
  :trusted_origins,
42
+ :serving_origins,
42
43
  :rate_limit,
43
44
  :session,
44
45
  :account,
@@ -48,6 +49,7 @@ module BetterAuth
48
49
  :email_and_password,
49
50
  :password_hasher,
50
51
  :email_verification,
52
+ :configured_social_provider_ids,
51
53
  :social_providers,
52
54
  :experimental,
53
55
  :secondary_storage,
@@ -60,6 +62,7 @@ module BetterAuth
60
62
  :logger
61
63
 
62
64
  def initialize(options = {})
65
+ @configured_social_provider_ids = extract_configured_social_provider_ids(options)
63
66
  options = symbolize_keys(options)
64
67
  @explicit_options = deep_dup(options)
65
68
 
@@ -75,7 +78,7 @@ module BetterAuth
75
78
  @hooks = options[:hooks]
76
79
  @on_api_error = symbolize_keys(options[:on_api_error] || options[:on_apierror] || {})
77
80
  @telemetry = symbolize_keys(options[:telemetry] || {})
78
- @social_providers = symbolize_keys(options[:social_providers] || {})
81
+ @social_providers = normalize_social_providers(options[:social_providers])
79
82
  @trusted_origins_callbacks = []
80
83
  @trusted_origins_callbacks << options[:trusted_origins] if options[:trusted_origins].respond_to?(:call)
81
84
  @trusted_origins_callback = combined_trusted_origins_callback
@@ -88,8 +91,8 @@ module BetterAuth
88
91
  @secret = legacy_secret || (test_environment? ? DEFAULT_SECRET : nil)
89
92
  @secret_config = @secret
90
93
  end
91
- @base_url_config = options[:base_url]
92
94
  @base_url, @context_base_url = normalize_base_url(options[:base_url])
95
+ @serving_origins = normalize_serving_origins(options[:serving_origins])
93
96
  @session = normalize_session(options[:session])
94
97
  @account = normalize_account(options[:account])
95
98
  @user = symbolize_keys(options[:user] || {})
@@ -110,26 +113,17 @@ module BetterAuth
110
113
  end
111
114
  end
112
115
 
113
- def base_url
114
- Thread.current[base_url_runtime_key] || @base_url
115
- end
116
-
117
- def set_runtime_base_url(value)
118
- Thread.current[base_url_runtime_key] = value
119
- end
120
-
121
- def clear_runtime_base_url!
122
- Thread.current[base_url_runtime_key] = nil
116
+ def explicit_trusted_origins
117
+ configured = @explicit_options[:trusted_origins]
118
+ origins = configured.respond_to?(:call) ? [] : Array(configured).compact
119
+ origins.concat(env_trusted_origins)
120
+ origins.map(&:to_s).reject(&:empty?).uniq
123
121
  end
124
122
 
125
123
  def production?
126
124
  production_environment?
127
125
  end
128
126
 
129
- def dynamic_base_url?
130
- URLHelpers.dynamic_config?(base_url_config)
131
- end
132
-
133
127
  def to_h
134
128
  {
135
129
  app_name: app_name,
@@ -140,6 +134,7 @@ module BetterAuth
140
134
  database: database,
141
135
  plugins: plugins,
142
136
  trusted_origins: trusted_origins,
137
+ serving_origins: serving_origins,
143
138
  rate_limit: rate_limit,
144
139
  session: session,
145
140
  account: account,
@@ -164,7 +159,7 @@ module BetterAuth
164
159
  normalized = symbolize_keys(defaults || {})
165
160
  normalized.each do |key, value|
166
161
  next unless respond_to?(key)
167
- next if key == :database_hooks
162
+ next if key == :database_hooks || key == :configured_social_provider_ids
168
163
 
169
164
  if key == :trusted_origins
170
165
  merge_trusted_origins_default(value)
@@ -231,15 +226,12 @@ module BetterAuth
231
226
 
232
227
  def normalize_base_url(value)
233
228
  configured = value || env_base_url
234
- return ["", ""] unless configured && !configured.empty?
235
-
236
- if URLHelpers.dynamic_config?(configured)
237
- validate_dynamic_base_url!(configured)
238
- resolved = URLHelpers.resolve_base_url(configured, base_path)
239
- return ["", ""] unless resolved
229
+ if configured.nil? || configured.to_s.empty?
230
+ raise Error, "base_url is required. Pass a canonical URL to BetterAuth.auth(base_url: ...) or set BETTER_AUTH_URL."
231
+ end
240
232
 
241
- uri = URI.parse(resolved)
242
- return [self.class.origin_for(uri), resolved.sub(%r{/+\z}, "")]
233
+ if configured.is_a?(Hash)
234
+ raise Error, "Dynamic base_url hashes are no longer supported. Configure a canonical base_url and list request-facing origins in serving_origins."
243
235
  end
244
236
 
245
237
  with_path = append_base_path(configured.to_s)
@@ -250,11 +242,6 @@ module BetterAuth
250
242
  raise Error, "Invalid base URL: #{configured}. Please provide a valid base URL."
251
243
  end
252
244
 
253
- def validate_dynamic_base_url!(value)
254
- allowed_hosts = value[:allowed_hosts] || value["allowed_hosts"] || value[:allowedHosts] || value["allowedHosts"]
255
- raise Error, "baseURL.allowedHosts cannot be empty" if allowed_hosts.respond_to?(:empty?) && allowed_hosts.empty?
256
- end
257
-
258
245
  def normalize_base_path(value)
259
246
  return "" if value.nil? || value == "" || value == "/"
260
247
 
@@ -368,27 +355,45 @@ module BetterAuth
368
355
  end
369
356
 
370
357
  def normalize_plugins(value)
371
- Array(value).compact.reject { |plugin| plugin == false }.map { |plugin| Plugin.coerce(plugin) }
358
+ Array(value).compact.reject { |plugin| plugin == false }.map do |plugin|
359
+ coerced = Plugin.coerce(plugin)
360
+ Plugins.ensure_plugin_loaded_for!(coerced)
361
+ coerced
362
+ end
363
+ end
364
+
365
+ def normalize_social_providers(value)
366
+ symbolize_keys(value || {}).reject do |_id, provider|
367
+ provider.nil? || provider == false || (provider.is_a?(Hash) && provider[:enabled] == false)
368
+ end
369
+ end
370
+
371
+ def extract_configured_social_provider_ids(options)
372
+ return [].freeze unless options.is_a?(Hash)
373
+
374
+ options.each_with_object([]) do |(key, providers), ids|
375
+ next unless normalize_key(key) == :social_providers && providers.is_a?(Hash)
376
+
377
+ ids.concat(providers.keys.map { |provider_id| provider_id.to_s.dup.freeze })
378
+ end.uniq.freeze
372
379
  end
373
380
 
374
381
  def normalize_trusted_origins(value)
375
- origins = []
376
- origins << base_url unless base_url.nil? || base_url.empty?
377
- origins.concat(dynamic_base_url_trusted_origins)
382
+ origins = serving_origins.dup
378
383
  origins.concat(Array(value).compact) unless value.respond_to?(:call)
379
384
  origins.concat(env_trusted_origins)
380
385
  origins.map(&:to_s).reject(&:empty?).uniq
381
386
  end
382
387
 
383
- def dynamic_base_url_trusted_origins
384
- return [] unless URLHelpers.dynamic_config?(base_url_config)
388
+ def normalize_serving_origins(value)
389
+ ([base_url] + Array(value).compact).map do |origin|
390
+ normalized = origin.to_s.sub(%r{/+\z}, "")
391
+ unless normalized.match?(%r{\Ahttps?://[^/?#@]+\z}i)
392
+ raise Error, "Invalid serving origin: #{origin}. serving_origins entries must be full http(s) origins without a path."
393
+ end
385
394
 
386
- protocol = base_url_config[:protocol] || base_url_config["protocol"] || "https"
387
- allowed_hosts = base_url_config[:allowed_hosts] || base_url_config["allowed_hosts"] || base_url_config[:allowedHosts] || base_url_config["allowedHosts"] || []
388
- Array(allowed_hosts).map do |host|
389
- host = host.to_s
390
- host.match?(%r{\Ahttps?://}i) ? host : "#{protocol}://#{host}"
391
- end
395
+ normalized
396
+ end.uniq
392
397
  end
393
398
 
394
399
  def merge_trusted_origins_default(value)
@@ -473,10 +478,6 @@ module BetterAuth
473
478
  end
474
479
  end
475
480
 
476
- def base_url_runtime_key
477
- :"better_auth_configuration_base_url_#{object_id}"
478
- end
479
-
480
481
  def test_environment?
481
482
  ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test" || ENV["APP_ENV"] == "test"
482
483
  end
@@ -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] = 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
@@ -150,12 +194,14 @@ module BetterAuth
150
194
  Crypto.symmetric_decode_jwt(value, ctx.context.secret_config, "better-auth-account")
151
195
  end
152
196
 
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)
197
+ def get_cookie_cache(request_or_cookie_header, secret:, strategy: "compact", version: nil, cookie_prefix: "better-auth", cookie_name: "session_data", is_secure: nil, cookie_full_name: nil)
154
198
  cookie_header = header_value(request_or_cookie_header)
155
199
  return nil if cookie_header.to_s.empty?
156
200
 
157
201
  parsed = parse_cookies(cookie_header)
158
- name = if is_secure.nil?
202
+ name = if cookie_full_name
203
+ cookie_full_name
204
+ elsif is_secure.nil?
159
205
  production_environment? ? "#{SECURE_COOKIE_PREFIX}#{cookie_prefix}.#{cookie_name}" : "#{cookie_prefix}.#{cookie_name}"
160
206
  else
161
207
  secure_prefix = is_secure ? SECURE_COOKIE_PREFIX : ""
@@ -166,6 +212,7 @@ module BetterAuth
166
212
 
167
213
  payload = decode_cookie_cache(raw, secret, strategy: strategy)
168
214
  return nil unless payload && payload["session"] && payload["user"]
215
+ return nil if embedded_session_expired?(payload["session"])
169
216
 
170
217
  expected_version = cookie_cache_version(version, payload["session"], payload["user"])
171
218
  return nil if version && (payload["version"] || "1") != expected_version
@@ -174,6 +221,7 @@ module BetterAuth
174
221
  end
175
222
 
176
223
  def expire_cookie(ctx, cookie)
224
+ remove_set_cookie_entries(ctx, cookie.name)
177
225
  ctx.set_cookie(cookie.name, "", cookie.attributes.merge(max_age: 0))
178
226
  end
179
227
 
@@ -182,6 +230,11 @@ module BetterAuth
182
230
  expire_cookie(ctx, ctx.context.auth_cookies[:session_data])
183
231
  expire_cookie(ctx, ctx.context.auth_cookies[:account_data]) if ctx.context.options.account[:store_account_cookie]
184
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
+
185
238
  store = SessionStore.new(ctx.context.auth_cookies[:session_data].name, ctx.context.auth_cookies[:session_data].attributes, ctx)
186
239
  store.set_cookies(store.clean)
187
240
  expire_cookie(ctx, ctx.context.auth_cookies[:dont_remember]) unless skip_dont_remember_me
@@ -248,6 +301,57 @@ module BetterAuth
248
301
  chunks.sort_by(&:first).map(&:last).join
249
302
  end
250
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
+
328
+ def decode_cookie_value(value)
329
+ decoded = URI.decode_uri_component(value)
330
+ decoded.valid_encoding? ? decoded : value
331
+ rescue ArgumentError
332
+ value
333
+ end
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
+
251
355
  def header_value(request_or_cookie_header)
252
356
  return request_or_cookie_header.headers["cookie"] if request_or_cookie_header.respond_to?(:headers)
253
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