better_auth 0.1.1 → 0.3.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +110 -18
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +589 -0
  6. data/lib/better_auth/adapters/memory.rb +235 -0
  7. data/lib/better_auth/adapters/mongodb.rb +9 -0
  8. data/lib/better_auth/adapters/mssql.rb +42 -0
  9. data/lib/better_auth/adapters/mysql.rb +33 -0
  10. data/lib/better_auth/adapters/postgres.rb +17 -0
  11. data/lib/better_auth/adapters/sql.rb +441 -0
  12. data/lib/better_auth/adapters/sqlite.rb +20 -0
  13. data/lib/better_auth/api.rb +226 -0
  14. data/lib/better_auth/api_error.rb +53 -0
  15. data/lib/better_auth/auth.rb +42 -0
  16. data/lib/better_auth/configuration.rb +399 -0
  17. data/lib/better_auth/context.rb +211 -0
  18. data/lib/better_auth/cookies.rb +278 -0
  19. data/lib/better_auth/core.rb +37 -1
  20. data/lib/better_auth/crypto/jwe.rb +76 -0
  21. data/lib/better_auth/crypto.rb +191 -0
  22. data/lib/better_auth/database_hooks.rb +114 -0
  23. data/lib/better_auth/endpoint.rb +326 -0
  24. data/lib/better_auth/error.rb +52 -0
  25. data/lib/better_auth/middleware/origin_check.rb +128 -0
  26. data/lib/better_auth/password.rb +120 -0
  27. data/lib/better_auth/plugin.rb +142 -0
  28. data/lib/better_auth/plugin_context.rb +16 -0
  29. data/lib/better_auth/plugin_registry.rb +67 -0
  30. data/lib/better_auth/plugins/access.rb +87 -0
  31. data/lib/better_auth/plugins/additional_fields.rb +29 -0
  32. data/lib/better_auth/plugins/admin/schema.rb +28 -0
  33. data/lib/better_auth/plugins/admin.rb +518 -0
  34. data/lib/better_auth/plugins/anonymous.rb +198 -0
  35. data/lib/better_auth/plugins/api_key.rb +16 -0
  36. data/lib/better_auth/plugins/bearer.rb +128 -0
  37. data/lib/better_auth/plugins/captcha.rb +159 -0
  38. data/lib/better_auth/plugins/custom_session.rb +84 -0
  39. data/lib/better_auth/plugins/device_authorization.rb +302 -0
  40. data/lib/better_auth/plugins/email_otp.rb +536 -0
  41. data/lib/better_auth/plugins/expo.rb +88 -0
  42. data/lib/better_auth/plugins/generic_oauth.rb +780 -0
  43. data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
  44. data/lib/better_auth/plugins/jwt.rb +482 -0
  45. data/lib/better_auth/plugins/last_login_method.rb +92 -0
  46. data/lib/better_auth/plugins/magic_link.rb +181 -0
  47. data/lib/better_auth/plugins/mcp.rb +342 -0
  48. data/lib/better_auth/plugins/multi_session.rb +173 -0
  49. data/lib/better_auth/plugins/oauth_protocol.rb +694 -0
  50. data/lib/better_auth/plugins/oauth_provider.rb +16 -0
  51. data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
  52. data/lib/better_auth/plugins/oidc_provider.rb +597 -0
  53. data/lib/better_auth/plugins/one_tap.rb +154 -0
  54. data/lib/better_auth/plugins/one_time_token.rb +106 -0
  55. data/lib/better_auth/plugins/open_api.rb +489 -0
  56. data/lib/better_auth/plugins/organization/schema.rb +106 -0
  57. data/lib/better_auth/plugins/organization.rb +995 -0
  58. data/lib/better_auth/plugins/passkey.rb +16 -0
  59. data/lib/better_auth/plugins/phone_number.rb +321 -0
  60. data/lib/better_auth/plugins/scim.rb +16 -0
  61. data/lib/better_auth/plugins/siwe.rb +242 -0
  62. data/lib/better_auth/plugins/sso.rb +16 -0
  63. data/lib/better_auth/plugins/stripe.rb +16 -0
  64. data/lib/better_auth/plugins/two_factor.rb +514 -0
  65. data/lib/better_auth/plugins/username.rb +278 -0
  66. data/lib/better_auth/plugins.rb +46 -0
  67. data/lib/better_auth/rate_limiter.rb +232 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +378 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +111 -0
  72. data/lib/better_auth/routes/error.rb +102 -0
  73. data/lib/better_auth/routes/ok.rb +15 -0
  74. data/lib/better_auth/routes/password.rb +183 -0
  75. data/lib/better_auth/routes/session.rb +160 -0
  76. data/lib/better_auth/routes/sign_in.rb +90 -0
  77. data/lib/better_auth/routes/sign_out.rb +15 -0
  78. data/lib/better_auth/routes/sign_up.rb +196 -0
  79. data/lib/better_auth/routes/social.rb +367 -0
  80. data/lib/better_auth/routes/user.rb +205 -0
  81. data/lib/better_auth/schema/sql.rb +202 -0
  82. data/lib/better_auth/schema.rb +291 -0
  83. data/lib/better_auth/session.rb +122 -0
  84. data/lib/better_auth/session_store.rb +91 -0
  85. data/lib/better_auth/social_providers/apple.rb +91 -0
  86. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  87. data/lib/better_auth/social_providers/base.rb +325 -0
  88. data/lib/better_auth/social_providers/cognito.rb +32 -0
  89. data/lib/better_auth/social_providers/discord.rb +81 -0
  90. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  91. data/lib/better_auth/social_providers/facebook.rb +35 -0
  92. data/lib/better_auth/social_providers/figma.rb +31 -0
  93. data/lib/better_auth/social_providers/github.rb +74 -0
  94. data/lib/better_auth/social_providers/gitlab.rb +67 -0
  95. data/lib/better_auth/social_providers/google.rb +90 -0
  96. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  97. data/lib/better_auth/social_providers/kakao.rb +32 -0
  98. data/lib/better_auth/social_providers/kick.rb +32 -0
  99. data/lib/better_auth/social_providers/line.rb +33 -0
  100. data/lib/better_auth/social_providers/linear.rb +44 -0
  101. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  102. data/lib/better_auth/social_providers/microsoft_entra_id.rb +137 -0
  103. data/lib/better_auth/social_providers/naver.rb +31 -0
  104. data/lib/better_auth/social_providers/notion.rb +33 -0
  105. data/lib/better_auth/social_providers/paybin.rb +31 -0
  106. data/lib/better_auth/social_providers/paypal.rb +36 -0
  107. data/lib/better_auth/social_providers/polar.rb +31 -0
  108. data/lib/better_auth/social_providers/railway.rb +49 -0
  109. data/lib/better_auth/social_providers/reddit.rb +32 -0
  110. data/lib/better_auth/social_providers/roblox.rb +31 -0
  111. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  112. data/lib/better_auth/social_providers/slack.rb +30 -0
  113. data/lib/better_auth/social_providers/spotify.rb +31 -0
  114. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  115. data/lib/better_auth/social_providers/twitch.rb +39 -0
  116. data/lib/better_auth/social_providers/twitter.rb +32 -0
  117. data/lib/better_auth/social_providers/vercel.rb +47 -0
  118. data/lib/better_auth/social_providers/vk.rb +34 -0
  119. data/lib/better_auth/social_providers/wechat.rb +104 -0
  120. data/lib/better_auth/social_providers/zoom.rb +31 -0
  121. data/lib/better_auth/social_providers.rb +38 -0
  122. data/lib/better_auth/version.rb +1 -1
  123. data/lib/better_auth.rb +86 -2
  124. metadata +233 -21
  125. data/.ruby-version +0 -1
  126. data/.standard.yml +0 -12
  127. data/.vscode/settings.json +0 -22
  128. data/AGENTS.md +0 -50
  129. data/CLAUDE.md +0 -1
  130. data/CODE_OF_CONDUCT.md +0 -173
  131. data/CONTRIBUTING.md +0 -187
  132. data/Gemfile +0 -12
  133. data/Makefile +0 -207
  134. data/Rakefile +0 -25
  135. data/SECURITY.md +0 -28
  136. data/docker-compose.yml +0 -63
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ class APIError < Error
5
+ STATUS_CODES = {
6
+ "BAD_REQUEST" => 400,
7
+ "UNAUTHORIZED" => 401,
8
+ "UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE" => 401,
9
+ "FORBIDDEN" => 403,
10
+ "CONFLICT" => 409,
11
+ "NOT_FOUND" => 404,
12
+ "METHOD_NOT_ALLOWED" => 405,
13
+ "UNPROCESSABLE_ENTITY" => 422,
14
+ "TOO_MANY_REQUESTS" => 429,
15
+ "BAD_GATEWAY" => 502,
16
+ "NOT_IMPLEMENTED" => 501,
17
+ "FOUND" => 302,
18
+ "INTERNAL_SERVER_ERROR" => 500
19
+ }.freeze
20
+
21
+ attr_reader :status, :status_code, :headers, :code, :body
22
+
23
+ def initialize(status, message: nil, headers: {}, code: nil, body: nil)
24
+ @status = status.to_s.upcase
25
+ @status_code = STATUS_CODES.fetch(@status, 500)
26
+ @headers = normalize_headers(headers)
27
+ @code = code || @status
28
+ @body = body
29
+ super(message || default_message)
30
+ end
31
+
32
+ def to_h
33
+ return body if body
34
+
35
+ {
36
+ code: code,
37
+ message: message
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def default_message
44
+ status.split("_").map(&:capitalize).join(" ")
45
+ end
46
+
47
+ def normalize_headers(headers)
48
+ headers.each_with_object({}) do |(key, value), result|
49
+ result[key.to_s.downcase] = value
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ class Auth
5
+ attr_reader :handler, :api, :options, :context, :error_codes
6
+
7
+ def initialize(options = {})
8
+ @options = Configuration.new(options)
9
+ @context = Context.new(@options)
10
+ @context.set_adapter(build_adapter)
11
+ @context.set_internal_adapter(Adapters::InternalAdapter.new(@context.adapter, @options))
12
+ @plugin_registry = PluginRegistry.new(@context)
13
+ @plugin_registry.run_init!
14
+ @error_codes = build_error_codes
15
+ @endpoints = build_endpoints
16
+ Router.check_endpoint_conflicts(@options, @options.logger)
17
+ @api = API.new(@context, @endpoints)
18
+ @handler = Router.new(@context, @endpoints)
19
+ end
20
+
21
+ def call(env)
22
+ handler.call(env)
23
+ end
24
+
25
+ private
26
+
27
+ def build_error_codes
28
+ @plugin_registry.error_codes(BASE_ERROR_CODES)
29
+ end
30
+
31
+ def build_adapter
32
+ return Adapters::Memory.new(options) if options.database.nil? || options.database == :memory
33
+ return options.database.call(options) if options.database.respond_to?(:call)
34
+
35
+ options.database
36
+ end
37
+
38
+ def build_endpoints
39
+ Core.base_endpoints.merge(@plugin_registry.endpoints)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "uri"
5
+
6
+ module BetterAuth
7
+ class Configuration
8
+ DEFAULT_BASE_PATH = "/api/auth"
9
+ DEFAULT_SECRET = "better-auth-secret-12345678901234567890"
10
+ DEFAULT_SESSION = {
11
+ update_age: 24 * 60 * 60,
12
+ expires_in: 60 * 60 * 24 * 7,
13
+ fresh_age: 60 * 60 * 24
14
+ }.freeze
15
+ DEFAULT_EMAIL_AND_PASSWORD = {
16
+ min_password_length: 8,
17
+ max_password_length: 128
18
+ }.freeze
19
+ DEFAULT_PASSWORD_HASHER = :scrypt
20
+ SUPPORTED_PASSWORD_HASHERS = [:scrypt, :bcrypt].freeze
21
+ DEFAULT_STATELESS_SESSION = {
22
+ cookie_cache: {
23
+ enabled: true,
24
+ strategy: "jwe",
25
+ refresh_cache: true
26
+ }
27
+ }.freeze
28
+ DEFAULT_STATELESS_ACCOUNT = {
29
+ store_state_strategy: "cookie",
30
+ store_account_cookie: true
31
+ }.freeze
32
+
33
+ attr_reader :app_name,
34
+ :base_url,
35
+ :base_path,
36
+ :context_base_url,
37
+ :secret,
38
+ :database,
39
+ :plugins,
40
+ :trusted_origins,
41
+ :rate_limit,
42
+ :session,
43
+ :account,
44
+ :user,
45
+ :verification,
46
+ :advanced,
47
+ :email_and_password,
48
+ :password_hasher,
49
+ :email_verification,
50
+ :social_providers,
51
+ :experimental,
52
+ :secondary_storage,
53
+ :database_hooks,
54
+ :hooks,
55
+ :on_api_error,
56
+ :disabled_paths,
57
+ :trusted_origins_callback,
58
+ :logger
59
+
60
+ def initialize(options = {})
61
+ options = symbolize_keys(options)
62
+ @explicit_options = deep_dup(options)
63
+
64
+ @logger = options[:logger]
65
+ @app_name = options[:app_name] || "Better Auth"
66
+ @base_path = normalize_base_path(options.fetch(:base_path, DEFAULT_BASE_PATH))
67
+ @database = options[:database]
68
+ @secondary_storage = options[:secondary_storage]
69
+ @plugins = normalize_plugins(options[:plugins])
70
+ @advanced = deep_merge({}, symbolize_keys(options[:advanced] || {}))
71
+ @disabled_paths = Array(options[:disabled_paths]).compact.map(&:to_s)
72
+ @database_hooks = options[:database_hooks]
73
+ @hooks = options[:hooks]
74
+ @on_api_error = symbolize_keys(options[:on_api_error] || options[:on_apierror] || {})
75
+ @social_providers = symbolize_keys(options[:social_providers] || {})
76
+ @trusted_origins_callback = options[:trusted_origins] if options[:trusted_origins].respond_to?(:call)
77
+ @secret = resolve_secret(options)
78
+ @base_url, @context_base_url = normalize_base_url(options[:base_url])
79
+ @session = normalize_session(options[:session])
80
+ @account = normalize_account(options[:account])
81
+ @user = symbolize_keys(options[:user] || {})
82
+ @verification = symbolize_keys(options[:verification] || {})
83
+ @email_and_password = normalize_email_and_password(options[:email_and_password])
84
+ @password_hasher = normalize_password_hasher(options[:password_hasher])
85
+ @email_verification = symbolize_keys(options[:email_verification] || {})
86
+ @experimental = normalize_experimental(options[:experimental])
87
+ @rate_limit = normalize_rate_limit(options[:rate_limit])
88
+ @trusted_origins = normalize_trusted_origins(options[:trusted_origins])
89
+
90
+ validate_secret
91
+ end
92
+
93
+ def trusted_origin?(url, allow_relative_paths: false)
94
+ trusted_origins.any? do |origin|
95
+ self.class.matches_origin_pattern?(url, origin, allow_relative_paths: allow_relative_paths)
96
+ end
97
+ end
98
+
99
+ def production?
100
+ production_environment?
101
+ end
102
+
103
+ def to_h
104
+ {
105
+ app_name: app_name,
106
+ base_url: base_url,
107
+ base_path: base_path,
108
+ secret: secret,
109
+ database: database,
110
+ plugins: plugins,
111
+ trusted_origins: trusted_origins,
112
+ rate_limit: rate_limit,
113
+ session: session,
114
+ account: account,
115
+ user: user,
116
+ verification: verification,
117
+ advanced: advanced,
118
+ email_and_password: email_and_password,
119
+ password_hasher: password_hasher,
120
+ email_verification: email_verification,
121
+ social_providers: social_providers,
122
+ experimental: experimental,
123
+ secondary_storage: secondary_storage,
124
+ database_hooks: database_hooks,
125
+ hooks: hooks,
126
+ on_api_error: on_api_error,
127
+ disabled_paths: disabled_paths
128
+ }
129
+ end
130
+
131
+ def merge_defaults!(defaults)
132
+ normalized = symbolize_keys(defaults || {})
133
+ normalized.each do |key, value|
134
+ next unless respond_to?(key)
135
+ next if key == :database_hooks
136
+
137
+ instance_variable_set("@#{key}", merge_default_value([key], public_send(key), value))
138
+ end
139
+ end
140
+
141
+ def self.matches_origin_pattern?(url, pattern, allow_relative_paths: false)
142
+ return relative_path_allowed?(url) if url.start_with?("/") && allow_relative_paths
143
+ return false if url.start_with?("/")
144
+
145
+ uri = parse_uri(url)
146
+ return false unless uri
147
+
148
+ if pattern.include?("*") || pattern.include?("?")
149
+ return wildcard_match?(pattern, origin_for(uri) || url) if pattern.include?("://")
150
+
151
+ return wildcard_match?(pattern, uri.host.to_s)
152
+ end
153
+
154
+ protocol = uri.scheme&.then { |scheme| "#{scheme}:" }
155
+ if protocol == "http:" || protocol == "https:" || protocol.nil?
156
+ pattern == origin_for(uri)
157
+ else
158
+ url.start_with?(pattern)
159
+ end
160
+ end
161
+
162
+ def self.relative_path_allowed?(url)
163
+ %r{\A/(?!/|\\|%2f|%5c)[\w\-.+/@]*(?:\?[\w\-.+/=&%@]*)?\z}i.match?(url)
164
+ end
165
+
166
+ def self.parse_uri(url)
167
+ URI.parse(url)
168
+ rescue URI::InvalidURIError
169
+ nil
170
+ end
171
+
172
+ def self.origin_for(uri)
173
+ return nil unless uri.scheme && uri.host
174
+
175
+ port = uri.port
176
+ default_port = (uri.scheme == "http" && port == 80) || (uri.scheme == "https" && port == 443)
177
+ host = uri.host
178
+ host = "[#{host}]" if host.include?(":") && !host.start_with?("[")
179
+ origin = "#{uri.scheme}://#{host}"
180
+ default_port ? origin : "#{origin}:#{port}"
181
+ end
182
+
183
+ def self.wildcard_match?(pattern, value)
184
+ regex = Regexp.escape(pattern).gsub("\\*", ".*").gsub("\\?", ".")
185
+ /\A#{regex}\z/.match?(value)
186
+ end
187
+
188
+ private
189
+
190
+ def normalize_base_url(value)
191
+ configured = value || env_base_url
192
+ return ["", ""] unless configured && !configured.empty?
193
+
194
+ with_path = append_base_path(configured.to_s)
195
+ uri = URI.parse(with_path)
196
+ validate_http_url!(uri, configured)
197
+ [self.class.origin_for(uri), with_path.sub(%r{/+\z}, "")]
198
+ rescue URI::InvalidURIError
199
+ raise Error, "Invalid base URL: #{configured}. Please provide a valid base URL."
200
+ end
201
+
202
+ def normalize_base_path(value)
203
+ return "" if value.nil? || value == "" || value == "/"
204
+
205
+ path = value.to_s
206
+ path.start_with?("/") ? path.sub(%r{/+\z}, "") : "/#{path.sub(%r{/+\z}, "")}"
207
+ end
208
+
209
+ def append_base_path(url)
210
+ uri = URI.parse(url)
211
+ validate_http_url!(uri, url)
212
+ path = uri.path.to_s.sub(%r{/+\z}, "")
213
+ has_path = !path.empty? && path != "/"
214
+ trimmed = url.to_s.sub(%r{/+\z}, "")
215
+ return trimmed if has_path || base_path.empty?
216
+
217
+ "#{trimmed}#{base_path}"
218
+ end
219
+
220
+ def validate_http_url!(uri, original)
221
+ return if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
222
+
223
+ raise Error, "Invalid base URL: #{original}. URL must include 'http://' or 'https://'"
224
+ end
225
+
226
+ def env_base_url
227
+ base_url = ENV["BASE_URL"]
228
+ [
229
+ ENV["BETTER_AUTH_URL"],
230
+ ENV["NEXT_PUBLIC_BETTER_AUTH_URL"],
231
+ ENV["PUBLIC_BETTER_AUTH_URL"],
232
+ ENV["NUXT_PUBLIC_BETTER_AUTH_URL"],
233
+ ENV["NUXT_PUBLIC_AUTH_URL"],
234
+ (base_url unless base_url == "/")
235
+ ].find { |value| value && !value.empty? }
236
+ end
237
+
238
+ def resolve_secret(options)
239
+ options[:secret] || ENV["BETTER_AUTH_SECRET"] || ENV["AUTH_SECRET"] || (test_environment? ? DEFAULT_SECRET : nil)
240
+ end
241
+
242
+ def validate_secret
243
+ if secret.nil? || secret.empty?
244
+ raise Error, "BETTER_AUTH_SECRET is missing. Set it in your environment or pass `secret` to BetterAuth.auth(secret: ...)."
245
+ end
246
+
247
+ return if test_environment? && secret == DEFAULT_SECRET
248
+
249
+ warn("[better-auth] Warning: your BETTER_AUTH_SECRET should be at least 32 characters long for adequate security.") if secret.length < 32
250
+ warn("[better-auth] Warning: your BETTER_AUTH_SECRET appears low-entropy. Use a randomly generated secret for production.") if entropy(secret) < 120
251
+ end
252
+
253
+ def entropy(value)
254
+ unique = value.chars.uniq.length
255
+ return 0 if unique.zero?
256
+
257
+ Math.log2(unique**value.length)
258
+ end
259
+
260
+ def normalize_session(value)
261
+ configured = symbolize_keys(value || {})
262
+ cookie_cache = symbolize_keys(configured.delete(:cookie_cache) || {})
263
+ session = deep_merge(DEFAULT_SESSION, configured)
264
+
265
+ if database.nil?
266
+ session = deep_merge(session, DEFAULT_STATELESS_SESSION)
267
+ else
268
+ session[:cookie_cache] = cookie_cache unless cookie_cache.empty?
269
+ end
270
+
271
+ session[:cookie_cache] = deep_merge(session[:cookie_cache] || {}, cookie_cache) unless cookie_cache.empty?
272
+ if (database || secondary_storage) && session.dig(:cookie_cache, :refresh_cache)
273
+ warn("[better-auth] `session.cookieCache.refreshCache` is enabled while `database` or `secondaryStorage` is configured. `refreshCache` is meant for stateless setups. Disabling `refreshCache`.")
274
+ session[:cookie_cache] = session[:cookie_cache].merge(refresh_cache: false)
275
+ end
276
+ session
277
+ end
278
+
279
+ def normalize_account(value)
280
+ configured = symbolize_keys(value || {})
281
+ database.nil? ? deep_merge(DEFAULT_STATELESS_ACCOUNT, configured) : configured
282
+ end
283
+
284
+ def normalize_email_and_password(value)
285
+ deep_merge(DEFAULT_EMAIL_AND_PASSWORD, symbolize_keys(value || {}))
286
+ end
287
+
288
+ def normalize_password_hasher(value)
289
+ hasher = (value || DEFAULT_PASSWORD_HASHER).to_sym
290
+ return hasher if SUPPORTED_PASSWORD_HASHERS.include?(hasher)
291
+
292
+ raise Error, "Unsupported password hasher: #{value}. Supported hashers are :scrypt and :bcrypt."
293
+ end
294
+
295
+ def normalize_experimental(value)
296
+ configured = symbolize_keys(value || {})
297
+ {
298
+ joins: !!configured[:joins]
299
+ }
300
+ end
301
+
302
+ def normalize_rate_limit(value)
303
+ configured = symbolize_keys(value || {})
304
+ {
305
+ enabled: configured.key?(:enabled) ? configured[:enabled] : production_environment?,
306
+ window: configured[:window] || 10,
307
+ max: configured[:max] || 100,
308
+ storage: configured[:storage] || (secondary_storage ? "secondary-storage" : "memory")
309
+ }.merge(configured)
310
+ end
311
+
312
+ def normalize_plugins(value)
313
+ Array(value).compact.reject { |plugin| plugin == false }.map { |plugin| Plugin.coerce(plugin) }
314
+ end
315
+
316
+ def normalize_trusted_origins(value)
317
+ origins = []
318
+ origins << base_url unless base_url.nil? || base_url.empty?
319
+ origins.concat(Array(value).compact) unless value.respond_to?(:call)
320
+ origins.concat(env_trusted_origins)
321
+ origins.map(&:to_s).reject(&:empty?).uniq
322
+ end
323
+
324
+ def env_trusted_origins
325
+ ENV.fetch("BETTER_AUTH_TRUSTED_ORIGINS", "").split(",").map(&:strip).reject(&:empty?)
326
+ end
327
+
328
+ def symbolize_keys(value)
329
+ return value unless value.is_a?(Hash)
330
+
331
+ value.each_with_object({}) do |(key, object_value), result|
332
+ normalized_key = normalize_key(key)
333
+ result[normalized_key] = object_value.is_a?(Hash) ? symbolize_keys(object_value) : object_value
334
+ end
335
+ end
336
+
337
+ def normalize_key(key)
338
+ key.to_s
339
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
340
+ .tr("-", "_")
341
+ .downcase
342
+ .to_sym
343
+ end
344
+
345
+ def deep_merge(base, override)
346
+ base.merge(override) do |_key, old_value, new_value|
347
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
348
+ deep_merge(old_value, new_value)
349
+ else
350
+ new_value
351
+ end
352
+ end
353
+ end
354
+
355
+ def merge_default_value(path, current, default)
356
+ if current.is_a?(Hash) && default.is_a?(Hash)
357
+ default.each_with_object(current.dup) do |(key, value), result|
358
+ result[key] = merge_default_value(path + [key], result[key], value)
359
+ end
360
+ else
361
+ return current if explicit_path?(path)
362
+
363
+ default
364
+ end
365
+ end
366
+
367
+ def explicit_path?(path)
368
+ path.reduce(@explicit_options) do |value, key|
369
+ return false unless value.is_a?(Hash) && value.key?(key)
370
+
371
+ value[key]
372
+ end
373
+ true
374
+ end
375
+
376
+ def deep_dup(value)
377
+ return value.transform_values { |entry| deep_dup(entry) } if value.is_a?(Hash)
378
+ return value.map { |entry| deep_dup(entry) } if value.is_a?(Array)
379
+
380
+ value
381
+ end
382
+
383
+ def warn(message)
384
+ if logger.respond_to?(:call)
385
+ logger.call(:warn, message)
386
+ elsif logger.respond_to?(:warn)
387
+ logger.warn(message)
388
+ end
389
+ end
390
+
391
+ def test_environment?
392
+ ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test" || ENV["APP_ENV"] == "test"
393
+ end
394
+
395
+ def production_environment?
396
+ ENV["RACK_ENV"] == "production" || ENV["RAILS_ENV"] == "production" || ENV["APP_ENV"] == "production"
397
+ end
398
+ end
399
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module BetterAuth
6
+ class Context
7
+ attr_reader :app_name,
8
+ :base_url,
9
+ :version,
10
+ :options,
11
+ :social_providers,
12
+ :cookies,
13
+ :auth_cookies,
14
+ :adapter,
15
+ :internal_adapter,
16
+ :logger,
17
+ :session_config,
18
+ :rate_limit_config,
19
+ :trusted_origins,
20
+ :secret,
21
+ :current_session,
22
+ :new_session
23
+
24
+ def initialize(configuration)
25
+ @app_name = configuration.app_name
26
+ @base_url = configuration.context_base_url
27
+ @version = BetterAuth::VERSION
28
+ @options = configuration
29
+ @social_providers = configuration.social_providers
30
+ @auth_cookies = Cookies.get_cookies(configuration)
31
+ @cookies = @auth_cookies
32
+ @adapter = configuration.database
33
+ @internal_adapter = nil
34
+ @logger = configuration.logger
35
+ @session_config = configuration.session
36
+ @rate_limit_config = configuration.rate_limit
37
+ @trusted_origins = configuration.trusted_origins
38
+ @secret = configuration.secret
39
+ @current_session = nil
40
+ @new_session = nil
41
+ end
42
+
43
+ def trusted_origin?(url, allow_relative_paths: false)
44
+ trusted_origins.any? do |origin|
45
+ Configuration.matches_origin_pattern?(url, origin, allow_relative_paths: allow_relative_paths)
46
+ end
47
+ end
48
+
49
+ def set_new_session(session)
50
+ @new_session = session
51
+ end
52
+
53
+ def set_current_session(session)
54
+ @current_session = session
55
+ end
56
+
57
+ def run_in_background(task)
58
+ handler = options.advanced.dig(:background_tasks, :handler)
59
+ if handler.respond_to?(:call)
60
+ handler.call(task)
61
+ elsif task.respond_to?(:call)
62
+ task.call
63
+ end
64
+ end
65
+
66
+ def create_auth_cookie(cookie_name, override_attributes = {})
67
+ Cookies.create_cookie(options, cookie_name.to_s, override_attributes)
68
+ end
69
+
70
+ def set_adapter(adapter)
71
+ @adapter = adapter
72
+ end
73
+
74
+ def set_internal_adapter(adapter)
75
+ @internal_adapter = adapter
76
+ end
77
+
78
+ def apply_plugin_context!(attributes)
79
+ normalize_context(attributes).each do |key, value|
80
+ instance_variable_set("@#{key}", value) if plugin_context_attribute?(key)
81
+ end
82
+ end
83
+
84
+ def refresh_from_options!
85
+ @social_providers = options.social_providers
86
+ @session_config = options.session
87
+ @rate_limit_config = options.rate_limit
88
+ @trusted_origins = options.trusted_origins
89
+ @secret = options.secret
90
+ end
91
+
92
+ def method_missing(name, *arguments, &block)
93
+ variable_name = :"@#{name}"
94
+ return instance_variable_get(variable_name) if arguments.empty? && instance_variable_defined?(variable_name)
95
+
96
+ super
97
+ end
98
+
99
+ def respond_to_missing?(name, include_private = false)
100
+ instance_variable_defined?(:"@#{name}") || super
101
+ end
102
+
103
+ 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)
108
+ end
109
+
110
+ def reset_runtime!
111
+ @current_session = nil
112
+ @new_session = nil
113
+ end
114
+
115
+ private
116
+
117
+ def inferred_base_url(request)
118
+ origin = inferred_origin(request)
119
+ path = options.base_path
120
+ path.empty? ? origin : "#{origin}#{path}"
121
+ end
122
+
123
+ def inferred_origin(request)
124
+ forwarded_host = request.get_header("HTTP_X_FORWARDED_HOST")
125
+ forwarded_proto = request.get_header("HTTP_X_FORWARDED_PROTO")
126
+ if options.advanced[:trusted_proxy_headers] && valid_forwarded?(forwarded_host, forwarded_proto)
127
+ return "#{forwarded_proto}://#{forwarded_host}"
128
+ end
129
+
130
+ scheme = request.get_header("rack.url_scheme") || request.scheme
131
+ scheme = "https" unless valid_proxy_proto?(scheme.to_s)
132
+ host_header = request.get_header("HTTP_HOST")
133
+ return "#{scheme}://#{host_header}" if host_header && valid_proxy_host?(host_header.to_s)
134
+
135
+ host = request.get_header("SERVER_NAME") || request.host
136
+ port = (request.get_header("SERVER_PORT") || request.port).to_i
137
+ default_port = (scheme == "http" && port == 80) || (scheme == "https" && port == 443)
138
+ default_port ? "#{scheme}://#{host}" : "#{scheme}://#{host}:#{port}"
139
+ end
140
+
141
+ def valid_forwarded?(host, proto)
142
+ valid_proxy_proto?(proto.to_s) && valid_proxy_host?(host.to_s)
143
+ end
144
+
145
+ def valid_proxy_proto?(proto)
146
+ %w[http https].include?(proto)
147
+ end
148
+
149
+ def valid_proxy_host?(host)
150
+ return false if host.strip.empty?
151
+
152
+ suspicious_patterns = [
153
+ /\.\./,
154
+ /\0/,
155
+ /\s/,
156
+ /\A[.]/,
157
+ /[<>'"]/,
158
+ /javascript:/i,
159
+ /file:/i,
160
+ /data:/i,
161
+ %r{[/\\]}
162
+ ]
163
+ return false if suspicious_patterns.any? { |pattern| host.match?(pattern) }
164
+
165
+ hostname = /\A[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(:[0-9]{1,5})?\z/
166
+ ipv4 = /\A(\d{1,3}\.){3}\d{1,3}(:[0-9]{1,5})?\z/
167
+ ipv6 = /\A\[[0-9a-fA-F:]+\](:[0-9]{1,5})?\z/
168
+ localhost = /\Alocalhost(:[0-9]{1,5})?\z/i
169
+ return false unless [hostname, ipv4, ipv6, localhost].any? { |pattern| host.match?(pattern) }
170
+
171
+ valid_port?(host)
172
+ end
173
+
174
+ def valid_port?(host)
175
+ port = host[/:(\d{1,5})\z/, 1]
176
+ return true unless port
177
+
178
+ port.to_i.between?(1, 65_535)
179
+ end
180
+
181
+ def current_trusted_origins(request)
182
+ origins = []
183
+ origins << Configuration.origin_for(URI.parse(base_url)) unless base_url.to_s.empty?
184
+ origins.concat(options.trusted_origins)
185
+ if options.trusted_origins_callback
186
+ origins.concat(Array(options.trusted_origins_callback.call(request)).compact)
187
+ end
188
+ origins.concat(ENV.fetch("BETTER_AUTH_TRUSTED_ORIGINS", "").split(",").map(&:strip))
189
+ origins.map(&:to_s).reject(&:empty?).uniq
190
+ rescue URI::InvalidURIError
191
+ options.trusted_origins
192
+ end
193
+
194
+ def normalize_context(value)
195
+ return {} unless value.is_a?(Hash)
196
+
197
+ value.each_with_object({}) do |(key, object), result|
198
+ normalized = key.to_s
199
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
200
+ .tr("-", "_")
201
+ .downcase
202
+ .to_sym
203
+ result[normalized] = object
204
+ end
205
+ end
206
+
207
+ def plugin_context_attribute?(key)
208
+ ![:options, :adapter, :internal_adapter].include?(key)
209
+ end
210
+ end
211
+ end