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,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module BetterAuth
6
+ module Plugins
7
+ USERNAME_ERROR_CODES = {
8
+ "INVALID_USERNAME_OR_PASSWORD" => "Invalid username or password",
9
+ "EMAIL_NOT_VERIFIED" => "Email not verified",
10
+ "UNEXPECTED_ERROR" => "Unexpected error",
11
+ "USERNAME_IS_ALREADY_TAKEN" => "Username is already taken. Please try another.",
12
+ "USERNAME_TOO_SHORT" => "Username is too short",
13
+ "USERNAME_TOO_LONG" => "Username is too long",
14
+ "INVALID_USERNAME" => "Username is invalid",
15
+ "INVALID_DISPLAY_USERNAME" => "Display username is invalid"
16
+ }.freeze
17
+
18
+ module_function
19
+
20
+ def username(options = {})
21
+ config = normalize_hash(options)
22
+
23
+ Plugin.new(
24
+ id: "username",
25
+ init: ->(_context) { {options: {database_hooks: username_database_hooks(config)}} },
26
+ endpoints: {
27
+ sign_in_username: sign_in_username_endpoint(config),
28
+ is_username_available: is_username_available_endpoint(config)
29
+ },
30
+ schema: username_schema(config),
31
+ hooks: {
32
+ before: [
33
+ {
34
+ matcher: ->(ctx) { username_mutation_path?(ctx.path) },
35
+ handler: ->(ctx) { validate_username_mutation!(ctx, config) }
36
+ },
37
+ {
38
+ matcher: ->(ctx) { username_mutation_path?(ctx.path) },
39
+ handler: ->(ctx) { mirror_username_fields!(ctx) }
40
+ }
41
+ ]
42
+ },
43
+ error_codes: USERNAME_ERROR_CODES,
44
+ options: config
45
+ )
46
+ end
47
+
48
+ def sign_in_username_endpoint(config)
49
+ Endpoint.new(
50
+ path: "/sign-in/username",
51
+ method: "POST",
52
+ metadata: {
53
+ allowed_media_types: [
54
+ "application/x-www-form-urlencoded",
55
+ "application/json"
56
+ ]
57
+ }
58
+ ) do |ctx|
59
+ body = normalize_hash(ctx.body)
60
+ raw_username = body[:username].to_s
61
+ password = body[:password].to_s
62
+ callback_url = body[:callback_url] || body[:callbackURL]
63
+ remember_me = body.key?(:remember_me) ? body[:remember_me] : body[:rememberMe]
64
+
65
+ if raw_username.empty? || password.empty?
66
+ raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"])
67
+ end
68
+
69
+ username = username_for_validation(raw_username, config)
70
+ validate_username!(username, config, status: "UNPROCESSABLE_ENTITY")
71
+
72
+ user = ctx.context.adapter.find_one(
73
+ model: "user",
74
+ where: [{field: "username", value: normalize_username(username, config)}]
75
+ )
76
+ unless user
77
+ Routes.hash_password(ctx, password)
78
+ raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"])
79
+ end
80
+
81
+ account = ctx.context.adapter.find_one(
82
+ model: "account",
83
+ where: [
84
+ {field: "userId", value: user["id"]},
85
+ {field: "providerId", value: "credential"}
86
+ ]
87
+ )
88
+ current_password = account && account["password"]
89
+ email_config = ctx.context.options.email_and_password
90
+ unless current_password && Routes.verify_password_value(ctx, password, current_password)
91
+ Routes.hash_password(ctx, password) unless current_password
92
+ raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"])
93
+ end
94
+
95
+ if email_config[:require_email_verification] && !user["emailVerified"]
96
+ Routes.send_sign_in_verification_email(ctx, user, callback_url)
97
+ raise APIError.new("FORBIDDEN", message: USERNAME_ERROR_CODES["EMAIL_NOT_VERIFIED"])
98
+ end
99
+
100
+ dont_remember_me = remember_me == false || remember_me.to_s == "false"
101
+ session = ctx.context.internal_adapter.create_session(
102
+ user["id"],
103
+ dont_remember_me,
104
+ Routes.session_overrides(ctx),
105
+ true
106
+ )
107
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session
108
+
109
+ Cookies.set_session_cookie(ctx, {session: session, user: user}, dont_remember_me)
110
+ ctx.json({
111
+ token: session["token"],
112
+ user: Schema.parse_output(ctx.context.options, "user", user)
113
+ })
114
+ end
115
+ end
116
+
117
+ def is_username_available_endpoint(config)
118
+ Endpoint.new(path: "/is-username-available", method: "POST") do |ctx|
119
+ body = normalize_hash(ctx.body)
120
+ username = body[:username].to_s
121
+ raise APIError.new("UNPROCESSABLE_ENTITY", message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) if username.empty?
122
+
123
+ validate_username!(username, config, status: "UNPROCESSABLE_ENTITY")
124
+ user = ctx.context.adapter.find_one(
125
+ model: "user",
126
+ where: [{field: "username", value: normalize_username(username, config)}]
127
+ )
128
+ ctx.json({available: user.nil?})
129
+ end
130
+ end
131
+
132
+ def username_schema(config)
133
+ {
134
+ user: {
135
+ fields: {
136
+ username: {
137
+ type: "string",
138
+ required: false,
139
+ sortable: true,
140
+ unique: true,
141
+ returned: true,
142
+ field_name: "username"
143
+ },
144
+ displayUsername: {
145
+ type: "string",
146
+ required: false,
147
+ field_name: "display_username"
148
+ }
149
+ }
150
+ }
151
+ }
152
+ end
153
+
154
+ def username_database_hooks(config)
155
+ before_hook = lambda do |user, _context|
156
+ data = user.dup
157
+ if data["username"].is_a?(String) && !data["username"].empty?
158
+ data["username"] = normalize_username(data["username"], config)
159
+ end
160
+ if data["displayUsername"].is_a?(String) && !data["displayUsername"].empty?
161
+ data["displayUsername"] = normalize_display_username(data["displayUsername"], config)
162
+ end
163
+ {data: data}
164
+ end
165
+
166
+ {
167
+ user: {
168
+ create: {before: before_hook},
169
+ update: {before: before_hook}
170
+ }
171
+ }
172
+ end
173
+
174
+ def validate_username_mutation!(ctx, config)
175
+ body = normalize_hash(ctx.body)
176
+ raw_username = body.key?(:username) ? body[:username] : nil
177
+ username = if raw_username.is_a?(String) && validation_order(config, :username) == "post-normalization"
178
+ normalize_username(raw_username, config)
179
+ else
180
+ raw_username
181
+ end
182
+
183
+ if username.is_a?(String)
184
+ validate_username!(username, config, status: "BAD_REQUEST")
185
+ existing = ctx.context.adapter.find_one(model: "user", where: [{field: "username", value: normalize_username(username, config)}])
186
+ current = (ctx.path == "/update-user") ? Routes.current_session(ctx, allow_nil: true) : nil
187
+ same_user = existing && current && existing["id"] == current[:session]["userId"]
188
+
189
+ if existing && ctx.path == "/sign-up/email"
190
+ raise APIError.new("UNPROCESSABLE_ENTITY", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
191
+ end
192
+
193
+ if existing && ctx.path == "/update-user" && !same_user
194
+ raise APIError.new("BAD_REQUEST", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
195
+ end
196
+ end
197
+
198
+ raw_display_username = body.key?(:display_username) ? body[:display_username] : nil
199
+ display_username = if raw_display_username.is_a?(String) && validation_order(config, :display_username) == "post-normalization"
200
+ normalize_display_username(raw_display_username, config)
201
+ else
202
+ raw_display_username
203
+ end
204
+
205
+ if display_username.is_a?(String)
206
+ validator = config[:display_username_validator]
207
+ unless !validator.respond_to?(:call) || validator.call(display_username)
208
+ raise APIError.new("BAD_REQUEST", message: USERNAME_ERROR_CODES["INVALID_DISPLAY_USERNAME"])
209
+ end
210
+ end
211
+ nil
212
+ end
213
+
214
+ def mirror_username_fields!(ctx)
215
+ body = normalize_hash(ctx.body)
216
+ body[:display_username] = body[:username] if present?(body[:username]) && !present?(body[:display_username])
217
+ body[:username] = body[:display_username] if present?(body[:display_username]) && !present?(body[:username])
218
+ ctx.body = body
219
+ nil
220
+ end
221
+
222
+ def validate_username!(username, config, status:)
223
+ if username.length < min_username_length(config)
224
+ raise APIError.new(status, message: USERNAME_ERROR_CODES["USERNAME_TOO_SHORT"])
225
+ end
226
+
227
+ if username.length > max_username_length(config)
228
+ raise APIError.new(status, message: USERNAME_ERROR_CODES["USERNAME_TOO_LONG"])
229
+ end
230
+
231
+ validator = config[:username_validator]
232
+ valid = validator.respond_to?(:call) ? validator.call(username) : default_username_valid?(username)
233
+ raise APIError.new(status, message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) unless valid
234
+ end
235
+
236
+ def username_for_validation(username, config)
237
+ (validation_order(config, :username) == "pre-normalization") ? normalize_username(username, config) : username
238
+ end
239
+
240
+ def normalize_username(username, config)
241
+ normalizer = config[:username_normalization]
242
+ return username if normalizer == false
243
+ return normalizer.call(username) if normalizer.respond_to?(:call)
244
+
245
+ username.downcase
246
+ end
247
+
248
+ def normalize_display_username(display_username, config)
249
+ normalizer = config[:display_username_normalization]
250
+ normalizer.respond_to?(:call) ? normalizer.call(display_username) : display_username
251
+ end
252
+
253
+ def validation_order(config, field)
254
+ order = config[:validation_order] || {}
255
+ order[field] || "pre-normalization"
256
+ end
257
+
258
+ def username_mutation_path?(path)
259
+ path == "/sign-up/email" || path == "/update-user"
260
+ end
261
+
262
+ def min_username_length(config)
263
+ (config[:min_username_length] || 3).to_i
264
+ end
265
+
266
+ def max_username_length(config)
267
+ (config[:max_username_length] || 30).to_i
268
+ end
269
+
270
+ def default_username_valid?(username)
271
+ username.match?(/\A[a-zA-Z0-9_.]+\z/)
272
+ end
273
+
274
+ def present?(value)
275
+ !value.nil? && value != false && !value.to_s.empty?
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def normalize_hash(value)
8
+ return {} unless value.is_a?(Hash)
9
+
10
+ value.each_with_object({}) do |(key, object), result|
11
+ result[normalize_key(key)] = object.is_a?(Hash) ? normalize_hash(object) : object
12
+ end
13
+ end
14
+
15
+ def normalize_key(key)
16
+ key.to_s
17
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
18
+ .tr("-", "_")
19
+ .downcase
20
+ .to_sym
21
+ end
22
+
23
+ def storage_fields(fields)
24
+ normalize_hash(fields).each_with_object({}) do |(key, value), result|
25
+ result[Schema.storage_key(key)] = normalize_field(value)
26
+ end
27
+ end
28
+
29
+ def normalize_field(value)
30
+ data = normalize_hash(value || {})
31
+ data[:default_value] = data.delete(:defaultValue) if data.key?(:defaultValue)
32
+ data[:field_name] = data.delete(:fieldName) if data.key?(:fieldName)
33
+ data
34
+ end
35
+
36
+ def fetch_value(data, key)
37
+ return nil unless data.respond_to?(:[])
38
+
39
+ data[key] || data[key.to_s] || data[Schema.storage_key(key)] || data[Schema.storage_key(key).to_sym] || data[normalize_key(key)]
40
+ end
41
+
42
+ def cookie_header_from_set_cookie(set_cookie)
43
+ set_cookie.to_s.lines.map { |line| line.split(";").first }.join("; ")
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BetterAuth
6
+ class RateLimiter
7
+ class MemoryStore
8
+ def initialize
9
+ @entries = {}
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def get(key)
14
+ @mutex.synchronize do
15
+ entry = @entries[key]
16
+ return nil unless entry
17
+
18
+ if Time.now.to_f >= entry[:expires_at]
19
+ @entries.delete(key)
20
+ return nil
21
+ end
22
+
23
+ entry[:data]
24
+ end
25
+ end
26
+
27
+ def set(key, value, ttl:, update: false)
28
+ @mutex.synchronize do
29
+ @entries[key] = {
30
+ data: value,
31
+ expires_at: Time.now.to_f + ttl.to_f
32
+ }
33
+ end
34
+ end
35
+ end
36
+
37
+ def initialize
38
+ @memory_store = MemoryStore.new
39
+ end
40
+
41
+ def call(request, context, path)
42
+ config = context.rate_limit_config || {}
43
+ return unless config[:enabled]
44
+
45
+ ip = client_ip(request, context.options)
46
+ return unless ip
47
+
48
+ rule = rate_limit_rule(request, context, config, path)
49
+ return if rule == false
50
+
51
+ window = rule[:window] || 10
52
+ max = rule[:max] || 100
53
+ key = rate_limit_key(ip, path)
54
+ now = Time.now.to_f
55
+ storage = storage_for(context, config)
56
+ data = read_storage(storage, key)
57
+
58
+ unless data
59
+ write_storage(storage, key, rate_limit_data(key, 1, now), ttl: window, update: false)
60
+ return
61
+ end
62
+
63
+ last_request = data.fetch(:last_request).to_f
64
+ count = data.fetch(:count).to_i
65
+ if should_rate_limit?(max.to_i, window.to_f, count, last_request, now)
66
+ return rate_limit_response(retry_after(last_request, window.to_f, now))
67
+ end
68
+
69
+ next_data = if now - last_request > window.to_f
70
+ rate_limit_data(key, 1, now)
71
+ else
72
+ rate_limit_data(key, count + 1, now)
73
+ end
74
+
75
+ write_storage(storage, key, next_data, ttl: window, update: true)
76
+ nil
77
+ end
78
+
79
+ private
80
+
81
+ def rate_limit_response(retry_after)
82
+ [
83
+ 429,
84
+ {"content-type" => "application/json", "x-retry-after" => retry_after.to_s},
85
+ [JSON.generate({message: "Too many requests. Please try again later."})]
86
+ ]
87
+ end
88
+
89
+ def should_rate_limit?(max, window, count, last_request, now)
90
+ now - last_request < window && count >= max
91
+ end
92
+
93
+ def retry_after(last_request, window, now)
94
+ [(last_request + window - now).ceil, 0].max
95
+ end
96
+
97
+ def rate_limit_data(key, count, last_request)
98
+ {
99
+ key: key,
100
+ count: count,
101
+ last_request: last_request
102
+ }
103
+ end
104
+
105
+ def rate_limit_rule(request, context, config, path)
106
+ rule = {
107
+ window: config[:window] || 10,
108
+ max: config[:max] || 100
109
+ }
110
+ rule = default_special_rule(path) || rule
111
+ rule = matching_plugin_rule(context, path) || rule
112
+ custom_rule = matching_custom_rule(config, path)
113
+ return resolve_custom_rule(custom_rule, request, rule) unless custom_rule.nil?
114
+
115
+ rule
116
+ end
117
+
118
+ def default_special_rule(path)
119
+ return unless path.start_with?("/sign-in", "/sign-up", "/change-password", "/change-email")
120
+
121
+ {window: 10, max: 3}
122
+ end
123
+
124
+ def matching_custom_rule(config, path)
125
+ custom_rules = config[:custom_rules] || {}
126
+ custom_rules.find do |pattern, _rule|
127
+ path_matches?(pattern.to_s, path)
128
+ end&.last
129
+ end
130
+
131
+ def resolve_custom_rule(rule, request, current)
132
+ return false if rule == false
133
+ return rule.call(request, current) if rule.respond_to?(:call)
134
+
135
+ rule || current
136
+ end
137
+
138
+ def storage_for(context, config)
139
+ return [:custom, config[:custom_storage]] if config[:custom_storage]
140
+
141
+ if config[:storage] == "secondary-storage" && context.options.secondary_storage
142
+ return [:secondary, context.options.secondary_storage]
143
+ end
144
+
145
+ [:memory, @memory_store]
146
+ end
147
+
148
+ def read_storage((type, storage), key)
149
+ data = storage.get(key)
150
+ data = JSON.parse(data) if type == :secondary && data.is_a?(String)
151
+ normalize_rate_limit_data(symbolize_keys(data))
152
+ rescue JSON::ParserError
153
+ nil
154
+ end
155
+
156
+ def write_storage((type, storage), key, data, ttl:, update:)
157
+ value = (type == :secondary) ? JSON.generate(secondary_storage_data(data)) : data
158
+ return call_secondary_storage_set(storage, key, value, ttl: ttl, update: update) if type == :secondary
159
+
160
+ call_storage_set(storage, key, value, ttl: ttl, update: update)
161
+ end
162
+
163
+ def secondary_storage_data(data)
164
+ {
165
+ key: data[:key],
166
+ count: data[:count],
167
+ lastRequest: (data[:last_request].to_f * 1000).to_i
168
+ }
169
+ end
170
+
171
+ def call_secondary_storage_set(storage, key, value, ttl:, update:)
172
+ storage.set(key, value, ttl)
173
+ rescue ArgumentError
174
+ call_storage_set(storage, key, value, ttl: ttl, update: update)
175
+ end
176
+
177
+ def call_storage_set(storage, key, value, ttl:, update:)
178
+ storage.set(key, value, ttl: ttl, update: update)
179
+ rescue ArgumentError
180
+ begin
181
+ storage.set(key, value, ttl, update)
182
+ rescue ArgumentError
183
+ begin
184
+ storage.set(key, value, ttl)
185
+ rescue ArgumentError
186
+ storage.set(key, value)
187
+ end
188
+ end
189
+ end
190
+
191
+ def symbolize_keys(value)
192
+ return value unless value.is_a?(Hash)
193
+
194
+ value.each_with_object({}) do |(key, object_value), result|
195
+ result[key.to_s.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").tr("-", "_").downcase.to_sym] = object_value
196
+ end
197
+ end
198
+
199
+ def normalize_rate_limit_data(data)
200
+ return data unless data.is_a?(Hash)
201
+
202
+ last_request = data[:last_request]
203
+ return data unless last_request.is_a?(Numeric) && last_request > 10_000_000_000
204
+
205
+ data.merge(last_request: last_request / 1000.0)
206
+ end
207
+
208
+ def rate_limit_key(ip, path)
209
+ "#{ip}|#{path}"
210
+ end
211
+
212
+ def client_ip(request, options)
213
+ RequestIP.client_ip(request, options)
214
+ end
215
+
216
+ def matching_plugin_rule(context, path)
217
+ context.options.plugins
218
+ .flat_map { |plugin| Array(plugin[:rate_limit]) }
219
+ .find do |rule|
220
+ matcher = rule[:path_matcher]
221
+ matcher&.call(path)
222
+ end
223
+ end
224
+
225
+ def path_matches?(pattern, path)
226
+ return path == pattern unless pattern.include?("*")
227
+
228
+ regex = Regexp.escape(pattern).gsub("\\*", ".*")
229
+ /\A#{regex}\z/.match?(path)
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module BetterAuth
6
+ module RequestIP
7
+ LOCALHOST_IP = "127.0.0.1"
8
+
9
+ module_function
10
+
11
+ def client_ip(request, options)
12
+ ip_options = options.advanced[:ip_address] || {}
13
+ return nil if ip_options[:disable_ip_tracking]
14
+
15
+ Array(ip_options[:ip_address_headers] || ["x-forwarded-for"]).each do |header|
16
+ value = header_value(request, header)
17
+ next unless value.is_a?(String)
18
+
19
+ ip = value.split(",").first.to_s.strip
20
+ return normalize_ip(ip, ipv6_subnet: ip_options[:ipv6_subnet]) if valid_ip?(ip)
21
+ end
22
+
23
+ ip = fallback_ip(request)
24
+ return normalize_ip(ip, ipv6_subnet: ip_options[:ipv6_subnet]) if valid_ip?(ip)
25
+
26
+ LOCALHOST_IP if test_or_development?
27
+ end
28
+
29
+ def header_value(request, header)
30
+ return request.get_header(rack_header_name(header)) if request.respond_to?(:get_header)
31
+ return request.headers[header.to_s.downcase] if request.respond_to?(:headers)
32
+ return request[header.to_s.downcase] || request[header.to_s] || request[header.to_sym] if request.is_a?(Hash)
33
+
34
+ nil
35
+ end
36
+
37
+ def fallback_ip(request)
38
+ return request.ip.to_s if request.respond_to?(:ip)
39
+
40
+ nil
41
+ end
42
+
43
+ def rack_header_name(header)
44
+ "HTTP_#{header.to_s.upcase.tr("-", "_")}"
45
+ end
46
+
47
+ def valid_ip?(ip)
48
+ return false if ip.to_s.empty? || ip.to_s.match?(/\s/)
49
+
50
+ IPAddr.new(ip)
51
+ true
52
+ rescue ArgumentError
53
+ false
54
+ end
55
+
56
+ def normalize_ip(ip, ipv6_subnet: nil)
57
+ address = IPAddr.new(ip)
58
+ return address.native.to_s if address.respond_to?(:ipv4_mapped?) && address.ipv4_mapped?
59
+ return address.to_s if address.ipv4?
60
+
61
+ address.mask((ipv6_subnet || 64).to_i).to_s
62
+ end
63
+
64
+ def test_or_development?
65
+ ["test", "development"].include?(ENV["RACK_ENV"]) ||
66
+ ["test", "development"].include?(ENV["RAILS_ENV"]) ||
67
+ ["test", "development"].include?(ENV["APP_ENV"])
68
+ end
69
+ end
70
+ end