better_auth 0.1.1 → 0.2.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +106 -16
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +439 -0
  6. data/lib/better_auth/adapters/memory.rb +232 -0
  7. data/lib/better_auth/adapters/mongodb.rb +369 -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 +425 -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 +210 -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 +129 -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 +348 -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 +990 -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 +215 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +365 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +108 -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 +164 -0
  75. data/lib/better_auth/routes/session.rb +137 -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 +145 -0
  79. data/lib/better_auth/routes/social.rb +188 -0
  80. data/lib/better_auth/routes/user.rb +193 -0
  81. data/lib/better_auth/schema/sql.rb +191 -0
  82. data/lib/better_auth/schema.rb +275 -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 +55 -0
  86. data/lib/better_auth/social_providers/base.rb +67 -0
  87. data/lib/better_auth/social_providers/discord.rb +59 -0
  88. data/lib/better_auth/social_providers/github.rb +59 -0
  89. data/lib/better_auth/social_providers/gitlab.rb +54 -0
  90. data/lib/better_auth/social_providers/google.rb +65 -0
  91. data/lib/better_auth/social_providers/microsoft_entra_id.rb +65 -0
  92. data/lib/better_auth/social_providers.rb +9 -0
  93. data/lib/better_auth/version.rb +1 -1
  94. data/lib/better_auth.rb +87 -2
  95. metadata +218 -21
  96. data/.ruby-version +0 -1
  97. data/.standard.yml +0 -12
  98. data/.vscode/settings.json +0 -22
  99. data/AGENTS.md +0 -50
  100. data/CLAUDE.md +0 -1
  101. data/CODE_OF_CONDUCT.md +0 -173
  102. data/CONTRIBUTING.md +0 -187
  103. data/Gemfile +0 -12
  104. data/Makefile +0 -207
  105. data/Rakefile +0 -25
  106. data/SECURITY.md +0 -28
  107. data/docker-compose.yml +0 -63
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ class DatabaseHooks
5
+ attr_reader :adapter, :options
6
+
7
+ def initialize(adapter, options)
8
+ @adapter = adapter
9
+ @options = options
10
+ end
11
+
12
+ def create(data, model, custom: nil, context: nil)
13
+ run_before(model, :create, data, context) do |actual_data|
14
+ created = custom ? custom.call(actual_data) : adapter.create(model: model, data: actual_data, force_allow_id: true)
15
+ run_after(model, :create, created)
16
+ created
17
+ end
18
+ end
19
+
20
+ def update(data, where, model, custom: nil, context: nil)
21
+ run_before(model, :update, data, context) do |actual_data|
22
+ updated = custom ? custom.call(actual_data) : adapter.update(model: model, where: where, update: actual_data)
23
+ run_after(model, :update, updated) if updated
24
+ updated
25
+ end
26
+ end
27
+
28
+ def update_many(data, where, model, custom: nil, context: nil)
29
+ run_before(model, :update, data, context) do |actual_data|
30
+ updated = custom ? custom.call(actual_data) : adapter.update_many(model: model, where: where, update: actual_data)
31
+ run_after(model, :update, updated) if updated
32
+ updated
33
+ end
34
+ end
35
+
36
+ def delete(where, model, custom: nil, context: nil)
37
+ entity = adapter.find_one(model: model, where: where)
38
+ return custom ? custom.call(where) : adapter.delete(model: model, where: where) unless entity
39
+
40
+ return nil if before_hooks(model, :delete).any? { |hook| hook.call(entity, context) == false }
41
+
42
+ deleted = custom ? custom.call(where) : adapter.delete(model: model, where: where)
43
+ after_hooks(model, :delete).each { |hook| hook.call(entity, context) }
44
+ deleted
45
+ end
46
+
47
+ def delete_many(where, model, custom: nil, context: nil)
48
+ entities = adapter.find_many(model: model, where: where)
49
+ entities.each do |entity|
50
+ return nil if before_hooks(model, :delete).any? { |hook| hook.call(entity, context) == false }
51
+ end
52
+ deleted = custom ? custom.call(where) : adapter.delete_many(model: model, where: where)
53
+ entities.each { |entity| after_hooks(model, :delete).each { |hook| hook.call(entity, context) } }
54
+ deleted
55
+ end
56
+
57
+ private
58
+
59
+ def run_before(model, action, data, context)
60
+ actual_data = stringify_keys(data)
61
+ before_hooks(model, action).each do |hook|
62
+ result = hook.call(actual_data, context)
63
+ return nil if result == false
64
+
65
+ hook_data = result.is_a?(Hash) ? (result[:data] || result["data"]) : nil
66
+ actual_data = actual_data.merge(stringify_keys(hook_data)) if hook_data
67
+ end
68
+ yield actual_data
69
+ end
70
+
71
+ def run_after(model, action, data)
72
+ after_hooks(model, action).each { |hook| hook.call(data, nil) }
73
+ end
74
+
75
+ def before_hooks(model, action)
76
+ hooks_for(model, action, :before)
77
+ end
78
+
79
+ def after_hooks(model, action)
80
+ hooks_for(model, action, :after)
81
+ end
82
+
83
+ def hooks_for(model, action, phase)
84
+ all_hooks.filter_map do |hooks|
85
+ model_hooks = hooks[model.to_sym] || hooks[model.to_s]
86
+ action_hooks = model_hooks&.fetch(action, nil) || model_hooks&.fetch(action.to_s, nil)
87
+ action_hooks&.fetch(phase, nil) || action_hooks&.fetch(phase.to_s, nil)
88
+ end
89
+ end
90
+
91
+ def all_hooks
92
+ direct = if options.database_hooks.nil?
93
+ []
94
+ elsif options.database_hooks.is_a?(Array)
95
+ options.database_hooks
96
+ else
97
+ [options.database_hooks]
98
+ end
99
+ plugin_hooks = options.plugins.filter_map do |plugin|
100
+ init_options = plugin.dig(:options, :database_hooks) || plugin.dig("options", "databaseHooks")
101
+ plugin[:database_hooks] || plugin["databaseHooks"] || init_options
102
+ end
103
+ direct + plugin_hooks
104
+ end
105
+
106
+ def stringify_keys(data)
107
+ return {} unless data
108
+
109
+ data.each_with_object({}) do |(key, value), result|
110
+ result[Schema.storage_key(key)] = value
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BetterAuth
6
+ class Endpoint
7
+ attr_reader :path,
8
+ :body_schema,
9
+ :query_schema,
10
+ :headers_schema,
11
+ :metadata,
12
+ :use,
13
+ :handler
14
+
15
+ def initialize(path: nil, method: nil, body_schema: nil, query_schema: nil, headers_schema: nil, metadata: {}, use: [], &handler)
16
+ @path = path
17
+ @methods = Array(method || "*").map { |value| value.to_s.upcase }
18
+ @body_schema = body_schema
19
+ @query_schema = query_schema
20
+ @headers_schema = headers_schema
21
+ @metadata = metadata || {}
22
+ @use = Array(use)
23
+ @handler = handler || ->(_ctx) {}
24
+ end
25
+
26
+ def methods
27
+ @methods.empty? ? ["*"] : @methods
28
+ end
29
+
30
+ def matches_method?(method)
31
+ methods.include?("*") || methods.include?(method.to_s.upcase)
32
+ end
33
+
34
+ def call(context)
35
+ apply_schemas!(context)
36
+
37
+ use.each do |middleware|
38
+ middleware_result = middleware.call(context)
39
+ return Result.from_value(middleware_result, context) if middleware_result
40
+ end
41
+
42
+ Result.from_value(handler.call(context), context)
43
+ end
44
+
45
+ private
46
+
47
+ def apply_schemas!(context)
48
+ context.body = validate_schema(:body, body_schema, context.body)
49
+ context.query = validate_schema(:query, query_schema, context.query)
50
+ context.headers = context.send(:normalize_headers, validate_schema(:headers, headers_schema, context.headers))
51
+ end
52
+
53
+ def validate_schema(_label, schema, value)
54
+ return value unless schema
55
+
56
+ parsed = parse_schema(schema, value)
57
+ return value if parsed.nil?
58
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if parsed == false
59
+
60
+ parsed
61
+ rescue APIError
62
+ raise
63
+ rescue => error
64
+ raise APIError.new("BAD_REQUEST", message: error.message.empty? ? BASE_ERROR_CODES["VALIDATION_ERROR"] : error.message)
65
+ end
66
+
67
+ def parse_schema(schema, value)
68
+ if schema.respond_to?(:parse)
69
+ schema.parse(value)
70
+ elsif schema.respond_to?(:call)
71
+ normalize_schema_result(schema.call(value))
72
+ else
73
+ value
74
+ end
75
+ end
76
+
77
+ def normalize_schema_result(result)
78
+ if result.respond_to?(:success?)
79
+ return result.to_h if result.success? && result.respond_to?(:to_h)
80
+ return false unless result.success?
81
+ end
82
+
83
+ result
84
+ end
85
+
86
+ class Result
87
+ attr_accessor :response, :status, :headers
88
+
89
+ def initialize(response:, status: 200, headers: {}, raw_response: nil)
90
+ @response = response
91
+ @status = status
92
+ @headers = normalize_headers(headers)
93
+ @raw_response = raw_response
94
+ end
95
+
96
+ def self.from_value(value, context)
97
+ return value if value.is_a?(self)
98
+
99
+ if value.is_a?(APIError)
100
+ return new(response: value, status: value.status_code, headers: value.headers)
101
+ end
102
+
103
+ if rack_response?(value)
104
+ return new(response: nil, status: value[0], headers: value[1], raw_response: value)
105
+ end
106
+
107
+ headers = context.response_headers.dup
108
+ if value.is_a?(self)
109
+ headers = merge_headers(headers, value.headers)
110
+ return new(response: value.response, status: value.status, headers: headers)
111
+ end
112
+
113
+ new(response: value, status: context.status, headers: headers)
114
+ end
115
+
116
+ def self.rack_response?(value)
117
+ value.is_a?(Array) && value.length == 3 && value[0].is_a?(Integer) && value[1].is_a?(Hash)
118
+ end
119
+
120
+ def self.merge_headers(base, extra)
121
+ extra.each_with_object(base.dup) do |(key, value), result|
122
+ normalized = key.to_s.downcase
123
+ result[normalized] = if normalized == "set-cookie" && result[normalized]
124
+ [result[normalized], value].join("\n")
125
+ else
126
+ value
127
+ end
128
+ end
129
+ end
130
+
131
+ def raw_response?
132
+ !@raw_response.nil?
133
+ end
134
+
135
+ def to_rack_response
136
+ return @raw_response if raw_response?
137
+
138
+ body = if response.nil?
139
+ [""]
140
+ elsif response.is_a?(String)
141
+ [response]
142
+ else
143
+ [JSON.generate(response)]
144
+ end
145
+ response_headers = {"content-type" => "application/json"}.merge(headers)
146
+ [status, response_headers, body]
147
+ end
148
+
149
+ private
150
+
151
+ def normalize_headers(headers)
152
+ headers.each_with_object({}) do |(key, value), result|
153
+ result[key.to_s.downcase] = value
154
+ end
155
+ end
156
+ end
157
+
158
+ class Context
159
+ attr_accessor :path,
160
+ :method,
161
+ :query,
162
+ :body,
163
+ :params,
164
+ :headers,
165
+ :context,
166
+ :request,
167
+ :status,
168
+ :returned,
169
+ :response_headers
170
+
171
+ def initialize(path:, method:, query:, body:, params:, headers:, context:, request: nil)
172
+ @path = path
173
+ @method = method.to_s.upcase
174
+ @query = query || {}
175
+ @body = body || {}
176
+ @params = params || {}
177
+ @headers = normalize_headers(headers || {})
178
+ @context = context
179
+ @request = request
180
+ @status = 200
181
+ @response_headers = {}
182
+ @returned = nil
183
+ end
184
+
185
+ def set_status(value)
186
+ @status = value
187
+ end
188
+
189
+ def set_header(key, value)
190
+ normalized = safe_header_name(key)
191
+ safe_value = safe_header_value(value)
192
+ response_headers[normalized] = if normalized == "set-cookie" && response_headers[normalized]
193
+ [response_headers[normalized], safe_value].join("\n")
194
+ else
195
+ safe_value
196
+ end
197
+ end
198
+
199
+ def set_cookie(name, value, options = {})
200
+ attributes = cookie_attributes(options)
201
+ cookie = (["#{name}=#{value}"] + attributes).join("; ")
202
+ set_header("set-cookie", cookie)
203
+ end
204
+
205
+ def get_cookie(name)
206
+ cookies[name.to_s]
207
+ end
208
+
209
+ def cookies
210
+ BetterAuth::Cookies.parse_cookies(headers["cookie"])
211
+ end
212
+
213
+ def set_signed_cookie(name, value, secret, options = {})
214
+ signature = BetterAuth::Crypto.hmac_signature(value, secret, encoding: :base64url)
215
+ set_cookie(name, "#{value}.#{signature}", options)
216
+ end
217
+
218
+ def get_signed_cookie(name, secret)
219
+ value = get_cookie(name)
220
+ return nil unless value
221
+
222
+ payload, signature = value.rpartition(".").values_at(0, 2)
223
+ return nil if payload.empty? || signature.empty?
224
+
225
+ BetterAuth::Crypto.verify_hmac_signature(payload, signature, secret, encoding: :base64url) ? payload : nil
226
+ end
227
+
228
+ def json(value, status: nil, headers: {})
229
+ set_status(status) if status
230
+ headers.each { |key, header_value| set_header(key, header_value) }
231
+ Result.new(response: value, status: self.status, headers: response_headers)
232
+ end
233
+
234
+ def error(status, message: nil, headers: {})
235
+ APIError.new(status, message: message, headers: headers)
236
+ end
237
+
238
+ def redirect(location, status: 302)
239
+ code = (status == 302) ? "FOUND" : status
240
+ APIError.new(code, message: "Redirect", headers: {"location" => location})
241
+ end
242
+
243
+ def merge_context!(data)
244
+ data.each do |key, value|
245
+ case key.to_sym
246
+ when :query
247
+ @query = deep_merge(query, value)
248
+ when :body
249
+ @body = deep_merge(body, value)
250
+ when :params
251
+ @params = deep_merge(params, value)
252
+ when :headers
253
+ @headers = normalize_headers(deep_merge(headers, value))
254
+ else
255
+ public_send("#{key}=", value) if respond_to?("#{key}=")
256
+ end
257
+ end
258
+ end
259
+
260
+ private
261
+
262
+ def normalize_headers(value)
263
+ value.each_with_object({}) do |(key, header_value), result|
264
+ result[key.to_s.downcase.tr("_", "-")] = header_value
265
+ end
266
+ end
267
+
268
+ def deep_merge(base, override)
269
+ return override unless base.is_a?(Hash) && override.is_a?(Hash)
270
+
271
+ base.merge(override) do |_key, old_value, new_value|
272
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
273
+ deep_merge(old_value, new_value)
274
+ else
275
+ new_value
276
+ end
277
+ end
278
+ end
279
+
280
+ def safe_header_name(value)
281
+ name = value.to_s.downcase
282
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "Invalid header name") if name.match?(/[\r\n]/)
283
+
284
+ name
285
+ end
286
+
287
+ def safe_header_value(value)
288
+ header_value = value.to_s
289
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "Invalid header value") if header_value.match?(/[\r\n]/)
290
+
291
+ header_value
292
+ end
293
+
294
+ def cookie_attributes(options)
295
+ options.compact.filter_map do |key, option_value|
296
+ next if option_value == false
297
+
298
+ name = cookie_attribute_name(key)
299
+ if option_value == true
300
+ name
301
+ else
302
+ "#{name}=#{cookie_attribute_value(key, option_value)}"
303
+ end
304
+ end
305
+ end
306
+
307
+ def cookie_attribute_name(key)
308
+ case key.to_sym
309
+ when :max_age then "Max-Age"
310
+ when :http_only, :httponly then "HttpOnly"
311
+ when :same_site, :samesite then "SameSite"
312
+ else
313
+ key.to_s.split("_").map(&:capitalize).join("-")
314
+ end
315
+ end
316
+
317
+ def cookie_attribute_value(key, value)
318
+ if [:same_site, :samesite].include?(key.to_sym)
319
+ return value.to_s.capitalize
320
+ end
321
+
322
+ value
323
+ end
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ class Error < StandardError
5
+ end
6
+
7
+ BASE_ERROR_CODES = {
8
+ "USER_NOT_FOUND" => "User not found",
9
+ "FAILED_TO_CREATE_USER" => "Failed to create user",
10
+ "FAILED_TO_CREATE_SESSION" => "Failed to create session",
11
+ "FAILED_TO_UPDATE_USER" => "Failed to update user",
12
+ "FAILED_TO_GET_SESSION" => "Failed to get session",
13
+ "INVALID_PASSWORD" => "Invalid password",
14
+ "INVALID_EMAIL" => "Invalid email",
15
+ "INVALID_EMAIL_OR_PASSWORD" => "Invalid email or password",
16
+ "SOCIAL_ACCOUNT_ALREADY_LINKED" => "Social account already linked",
17
+ "PROVIDER_NOT_FOUND" => "Provider not found",
18
+ "INVALID_TOKEN" => "Invalid token",
19
+ "ID_TOKEN_NOT_SUPPORTED" => "id_token not supported",
20
+ "FAILED_TO_GET_USER_INFO" => "Failed to get user info",
21
+ "USER_EMAIL_NOT_FOUND" => "User email not found",
22
+ "EMAIL_NOT_VERIFIED" => "Email not verified",
23
+ "PASSWORD_TOO_SHORT" => "Password too short",
24
+ "PASSWORD_TOO_LONG" => "Password too long",
25
+ "USER_ALREADY_EXISTS" => "User already exists.",
26
+ "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL" => "User already exists. Use another email.",
27
+ "EMAIL_CAN_NOT_BE_UPDATED" => "Email can not be updated",
28
+ "CREDENTIAL_ACCOUNT_NOT_FOUND" => "Credential account not found",
29
+ "SESSION_EXPIRED" => "Session expired. Re-authenticate to perform this action.",
30
+ "FAILED_TO_UNLINK_LAST_ACCOUNT" => "You can't unlink your last account",
31
+ "ACCOUNT_NOT_FOUND" => "Account not found",
32
+ "USER_ALREADY_HAS_PASSWORD" => "User already has a password. Provide that to delete the account.",
33
+ "CROSS_SITE_NAVIGATION_LOGIN_BLOCKED" => "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
34
+ "VERIFICATION_EMAIL_NOT_ENABLED" => "Verification email isn't enabled",
35
+ "EMAIL_ALREADY_VERIFIED" => "Email is already verified",
36
+ "EMAIL_MISMATCH" => "Email mismatch",
37
+ "SESSION_NOT_FRESH" => "Session is not fresh",
38
+ "LINKED_ACCOUNT_ALREADY_EXISTS" => "Linked account already exists",
39
+ "INVALID_ORIGIN" => "Invalid origin",
40
+ "INVALID_CALLBACK_URL" => "Invalid callbackURL",
41
+ "INVALID_REDIRECT_URL" => "Invalid redirectURL",
42
+ "INVALID_ERROR_CALLBACK_URL" => "Invalid errorCallbackURL",
43
+ "INVALID_NEW_USER_CALLBACK_URL" => "Invalid newUserCallbackURL",
44
+ "MISSING_OR_NULL_ORIGIN" => "Missing or null Origin",
45
+ "CALLBACK_URL_REQUIRED" => "callbackURL is required",
46
+ "FAILED_TO_CREATE_VERIFICATION" => "Unable to create verification",
47
+ "FIELD_NOT_ALLOWED" => "Field not allowed to be set",
48
+ "ASYNC_VALIDATION_NOT_SUPPORTED" => "Async validation is not supported",
49
+ "VALIDATION_ERROR" => "Validation Error",
50
+ "MISSING_FIELD" => "Field is required"
51
+ }.freeze
52
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Middleware
5
+ class OriginCheck
6
+ DEPRECATION_WARNING = "[Deprecation] disableOriginCheck: true currently also disables CSRF checks. In a future version, disableOriginCheck will ONLY disable URL validation. To keep CSRF disabled, add disableCSRFCheck: true to your config."
7
+
8
+ def initialize
9
+ @warned_backward_compat = false
10
+ end
11
+
12
+ def call(endpoint_context)
13
+ return if %w[GET OPTIONS HEAD].include?(endpoint_context.method)
14
+
15
+ validate_origin(endpoint_context)
16
+ validate_fetch_metadata(endpoint_context)
17
+ return if skip_origin_check?(endpoint_context)
18
+
19
+ validate_callback_urls(endpoint_context)
20
+ nil
21
+ rescue APIError => error
22
+ Endpoint::Result.new(response: error.to_h, status: error.status_code, headers: error.headers).to_rack_response
23
+ end
24
+
25
+ private
26
+
27
+ def validate_origin(endpoint_context, force: false)
28
+ return if skip_csrf_check?(endpoint_context)
29
+ return if skip_csrf_for_backward_compat?(endpoint_context)
30
+ return if skip_origin_path?(endpoint_context)
31
+
32
+ headers = endpoint_context.headers
33
+ should_validate = force || headers.key?("cookie")
34
+ return unless should_validate
35
+
36
+ origin = headers["origin"] || headers["referer"] || ""
37
+ if origin.empty? || origin == "null"
38
+ raise APIError.new("FORBIDDEN", message: BASE_ERROR_CODES["MISSING_OR_NULL_ORIGIN"])
39
+ end
40
+
41
+ unless endpoint_context.context.trusted_origin?(origin)
42
+ log(endpoint_context.context, :error, "Invalid origin: #{origin}")
43
+ raise APIError.new("FORBIDDEN", message: "Invalid origin")
44
+ end
45
+ end
46
+
47
+ def validate_fetch_metadata(endpoint_context)
48
+ return if skip_csrf_check?(endpoint_context)
49
+ return if skip_csrf_for_backward_compat?(endpoint_context)
50
+
51
+ headers = endpoint_context.headers
52
+ return if headers.key?("cookie")
53
+
54
+ site = headers["sec-fetch-site"]
55
+ mode = headers["sec-fetch-mode"]
56
+ dest = headers["sec-fetch-dest"]
57
+ has_metadata = [site, mode, dest].any? { |value| value && !value.to_s.strip.empty? }
58
+ return unless has_metadata
59
+
60
+ if site == "cross-site" && mode == "navigate"
61
+ log(endpoint_context.context, :error, "Blocked cross-site navigation login attempt (CSRF protection)")
62
+ raise APIError.new("FORBIDDEN", message: BASE_ERROR_CODES["CROSS_SITE_NAVIGATION_LOGIN_BLOCKED"])
63
+ end
64
+
65
+ validate_origin(endpoint_context, force: true)
66
+ end
67
+
68
+ def validate_callback_urls(endpoint_context)
69
+ {
70
+ "callbackURL" => "callbackURL",
71
+ "redirectTo" => "redirectURL",
72
+ "errorCallbackURL" => "errorCallbackURL",
73
+ "newUserCallbackURL" => "newUserCallbackURL"
74
+ }.each do |key, label|
75
+ value = fetch_data(endpoint_context.body, key) || fetch_data(endpoint_context.query, key)
76
+ next if value.nil? || value == ""
77
+
78
+ unless endpoint_context.context.trusted_origin?(value, allow_relative_paths: label != "origin")
79
+ log(endpoint_context.context, :error, "Invalid #{label}: #{value}")
80
+ raise APIError.new("FORBIDDEN", message: "Invalid #{label}")
81
+ end
82
+ end
83
+ end
84
+
85
+ def skip_csrf_check?(endpoint_context)
86
+ endpoint_context.context.options.advanced[:disable_csrf_check] == true
87
+ end
88
+
89
+ def skip_origin_check?(endpoint_context)
90
+ !!endpoint_context.context.options.advanced[:disable_origin_check]
91
+ end
92
+
93
+ def skip_csrf_for_backward_compat?(endpoint_context)
94
+ advanced = endpoint_context.context.options.advanced
95
+ return false unless advanced[:disable_origin_check] == true
96
+ return false if advanced.key?(:disable_csrf_check)
97
+
98
+ unless @warned_backward_compat
99
+ log(endpoint_context.context, :warn, DEPRECATION_WARNING)
100
+ @warned_backward_compat = true
101
+ end
102
+ true
103
+ end
104
+
105
+ def skip_origin_path?(endpoint_context)
106
+ skip = endpoint_context.context.options.advanced[:disable_origin_check]
107
+ return false unless skip.is_a?(Array)
108
+
109
+ skip.any? { |path| endpoint_context.path.start_with?(path.to_s) }
110
+ end
111
+
112
+ def fetch_data(data, key)
113
+ return unless data.is_a?(Hash)
114
+
115
+ data[key] || data[key.to_sym]
116
+ end
117
+
118
+ def log(context, level, message)
119
+ logger = context.logger
120
+ if logger.respond_to?(:call)
121
+ logger.call(level, message)
122
+ elsif logger.respond_to?(level)
123
+ logger.public_send(level, message)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end