better_auth 0.4.0 → 0.5.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +5 -5
  5. data/lib/better_auth/adapters/sql.rb +96 -18
  6. data/lib/better_auth/api.rb +113 -13
  7. data/lib/better_auth/configuration.rb +97 -7
  8. data/lib/better_auth/context.rb +165 -12
  9. data/lib/better_auth/cookies.rb +6 -4
  10. data/lib/better_auth/core.rb +2 -0
  11. data/lib/better_auth/crypto/jwe.rb +27 -5
  12. data/lib/better_auth/crypto.rb +32 -0
  13. data/lib/better_auth/database_hooks.rb +5 -5
  14. data/lib/better_auth/endpoint.rb +87 -3
  15. data/lib/better_auth/error.rb +8 -1
  16. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  17. data/lib/better_auth/plugins/admin.rb +344 -16
  18. data/lib/better_auth/plugins/anonymous.rb +37 -3
  19. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  20. data/lib/better_auth/plugins/dub.rb +148 -0
  21. data/lib/better_auth/plugins/email_otp.rb +246 -15
  22. data/lib/better_auth/plugins/expo.rb +17 -1
  23. data/lib/better_auth/plugins/generic_oauth.rb +53 -7
  24. data/lib/better_auth/plugins/jwt.rb +37 -4
  25. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  26. data/lib/better_auth/plugins/magic_link.rb +66 -3
  27. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  28. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  29. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  30. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  31. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  32. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  33. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  34. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  35. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  36. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  37. data/lib/better_auth/plugins/mcp.rb +111 -263
  38. data/lib/better_auth/plugins/multi_session.rb +61 -3
  39. data/lib/better_auth/plugins/oauth_protocol.rb +2 -2
  40. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  41. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  42. data/lib/better_auth/plugins/one_tap.rb +7 -2
  43. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  44. data/lib/better_auth/plugins/open_api.rb +163 -318
  45. data/lib/better_auth/plugins/organization.rb +135 -36
  46. data/lib/better_auth/plugins/phone_number.rb +141 -6
  47. data/lib/better_auth/plugins/siwe.rb +69 -3
  48. data/lib/better_auth/plugins/two_factor.rb +65 -23
  49. data/lib/better_auth/plugins/username.rb +57 -2
  50. data/lib/better_auth/rate_limiter.rb +20 -0
  51. data/lib/better_auth/response.rb +42 -0
  52. data/lib/better_auth/router.rb +7 -1
  53. data/lib/better_auth/routes/account.rb +204 -38
  54. data/lib/better_auth/routes/email_verification.rb +98 -14
  55. data/lib/better_auth/routes/password.rb +125 -8
  56. data/lib/better_auth/routes/session.rb +128 -13
  57. data/lib/better_auth/routes/sign_in.rb +24 -2
  58. data/lib/better_auth/routes/sign_out.rb +13 -1
  59. data/lib/better_auth/routes/sign_up.rb +62 -4
  60. data/lib/better_auth/routes/social.rb +102 -7
  61. data/lib/better_auth/routes/user.rb +222 -20
  62. data/lib/better_auth/routes/validation.rb +50 -0
  63. data/lib/better_auth/secret_config.rb +115 -0
  64. data/lib/better_auth/session.rb +1 -1
  65. data/lib/better_auth/url_helpers.rb +12 -1
  66. data/lib/better_auth/version.rb +1 -1
  67. data/lib/better_auth.rb +4 -0
  68. metadata +15 -1
@@ -1,12 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+ require "uri"
5
+
3
6
  module BetterAuth
4
7
  module Routes
5
8
  def self.update_user
6
- Endpoint.new(path: "/update-user", method: "POST") do |ctx|
9
+ Endpoint.new(
10
+ path: "/update-user",
11
+ method: "POST",
12
+ metadata: {
13
+ openapi: {
14
+ operationId: "updateUser",
15
+ description: "Update the current user's profile",
16
+ requestBody: OpenAPI.json_request_body(
17
+ OpenAPI.object_schema(
18
+ {
19
+ name: {type: ["string", "null"], description: "The user's name"},
20
+ image: {type: ["string", "null"], description: "The user's profile image URL"}
21
+ }
22
+ )
23
+ ),
24
+ responses: {
25
+ "200" => OpenAPI.json_response("User updated", OpenAPI.status_response_schema)
26
+ }
27
+ }
28
+ }
29
+ ) do |ctx|
7
30
  session = current_session(ctx)
31
+ raise APIError.new("BAD_REQUEST", code: "BODY_MUST_BE_AN_OBJECT", message: BASE_ERROR_CODES["BODY_MUST_BE_AN_OBJECT"]) unless ctx.body.is_a?(Hash)
32
+
8
33
  body = normalize_hash(ctx.body)
9
- raise APIError.new("BAD_REQUEST", message: "Body must be an object") unless body.is_a?(Hash)
10
34
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["EMAIL_CAN_NOT_BE_UPDATED"]) if body.key?("email")
11
35
  update = parse_declared_input(ctx, "user", body, allowed_base: ["name", "image"])
12
36
  raise APIError.new("BAD_REQUEST", message: "No fields to update") if update.empty?
@@ -18,7 +42,38 @@ module BetterAuth
18
42
  end
19
43
 
20
44
  def self.change_password
21
- Endpoint.new(path: "/change-password", method: "POST") do |ctx|
45
+ Endpoint.new(
46
+ path: "/change-password",
47
+ method: "POST",
48
+ metadata: {
49
+ openapi: {
50
+ description: "Change the password of the user",
51
+ operationId: "changePassword",
52
+ requestBody: OpenAPI.json_request_body(
53
+ OpenAPI.object_schema(
54
+ {
55
+ newPassword: {type: "string", description: "The new password to set"},
56
+ currentPassword: {type: "string", description: "The current password is required"},
57
+ revokeOtherSessions: {type: ["boolean", "null"], description: "Must be a boolean value"}
58
+ },
59
+ required: ["newPassword", "currentPassword"]
60
+ )
61
+ ),
62
+ responses: {
63
+ "200" => OpenAPI.json_response(
64
+ "Password successfully changed",
65
+ OpenAPI.object_schema(
66
+ {
67
+ token: {type: "string", nullable: true, description: "New session token if other sessions were revoked"},
68
+ user: OpenAPI.user_response_schema
69
+ },
70
+ required: ["user"]
71
+ )
72
+ )
73
+ }
74
+ }
75
+ }
76
+ ) do |ctx|
22
77
  session = current_session(ctx, sensitive: true)
23
78
  body = normalize_hash(ctx.body)
24
79
  new_password = body["newPassword"] || body["new_password"]
@@ -42,13 +97,33 @@ module BetterAuth
42
97
  end
43
98
 
44
99
  def self.set_password
45
- Endpoint.new(path: "/set-password", method: "POST") do |ctx|
100
+ Endpoint.new(
101
+ path: "/set-password",
102
+ method: "POST",
103
+ metadata: {
104
+ openapi: {
105
+ operationId: "setPassword",
106
+ description: "Set a password for the current user",
107
+ requestBody: OpenAPI.json_request_body(
108
+ OpenAPI.object_schema(
109
+ {
110
+ newPassword: {type: "string", description: "The password to set"}
111
+ },
112
+ required: ["newPassword"]
113
+ )
114
+ ),
115
+ responses: {
116
+ "200" => OpenAPI.json_response("Password set", OpenAPI.status_response_schema)
117
+ }
118
+ }
119
+ }
120
+ ) do |ctx|
46
121
  session = current_session(ctx, sensitive: true)
47
122
  body = normalize_hash(ctx.body)
48
123
  new_password = body["newPassword"] || body["new_password"]
49
124
  validate_password_length!(new_password, ctx.context.options.email_and_password)
50
125
  account = credential_account(ctx, session[:user]["id"])
51
- raise APIError.new("BAD_REQUEST", message: "user already has a password") if account && account["password"]
126
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_ALREADY_SET"]) if account && account["password"]
52
127
 
53
128
  ctx.context.internal_adapter.link_account(
54
129
  userId: session[:user]["id"],
@@ -61,7 +136,37 @@ module BetterAuth
61
136
  end
62
137
 
63
138
  def self.delete_user
64
- Endpoint.new(path: "/delete-user", method: "POST") do |ctx|
139
+ Endpoint.new(
140
+ path: "/delete-user",
141
+ method: "POST",
142
+ metadata: {
143
+ openapi: {
144
+ operationId: "deleteUser",
145
+ description: "Delete the current user",
146
+ requestBody: OpenAPI.json_request_body(
147
+ OpenAPI.object_schema(
148
+ {
149
+ password: {type: ["string", "null"], description: "The user's password"},
150
+ token: {type: ["string", "null"], description: "Delete account verification token"},
151
+ callbackURL: {type: ["string", "null"], description: "The URL to redirect to after deletion"}
152
+ }
153
+ )
154
+ ),
155
+ responses: {
156
+ "200" => OpenAPI.json_response(
157
+ "User deleted or verification email sent",
158
+ OpenAPI.object_schema(
159
+ {
160
+ success: {type: "boolean"},
161
+ message: {type: "string"}
162
+ },
163
+ required: ["success", "message"]
164
+ )
165
+ )
166
+ }
167
+ }
168
+ }
169
+ ) do |ctx|
65
170
  enabled = ctx.context.options.user.dig(:delete_user, :enabled)
66
171
  raise APIError.new("NOT_FOUND") unless enabled
67
172
 
@@ -80,12 +185,14 @@ module BetterAuth
80
185
  elsif sender
81
186
  token = SecureRandom.hex(16)
82
187
  expires_in = ctx.context.options.user.dig(:delete_user, :delete_token_expires_in) || 3600
188
+ callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"] || "/"
189
+ url = "#{ctx.context.base_url}/delete-user/callback?token=#{URI.encode_www_form_component(token)}&callbackURL=#{URI.encode_www_form_component(callback_url)}"
83
190
  ctx.context.internal_adapter.create_verification_value(
84
191
  identifier: "delete-account-#{token}",
85
192
  value: session[:user]["id"],
86
193
  expiresAt: Time.now + expires_in.to_i
87
194
  )
88
- sender.call({user: session[:user], token: token}, ctx.request)
195
+ sender.call({user: session[:user], url: url, token: token}, ctx.request)
89
196
  next ctx.json({success: true, message: "Verification email sent"})
90
197
  elsif !body["password"]
91
198
  require_fresh_session!(ctx, session)
@@ -97,14 +204,49 @@ module BetterAuth
97
204
  end
98
205
 
99
206
  def self.delete_user_callback
100
- Endpoint.new(path: "/delete-user/callback", method: "GET") do |ctx|
207
+ Endpoint.new(
208
+ path: "/delete-user/callback",
209
+ method: "GET",
210
+ metadata: {
211
+ openapi: {
212
+ operationId: "deleteUserCallback",
213
+ description: "Delete the current user using a verification token",
214
+ parameters: [
215
+ {
216
+ name: "token",
217
+ in: "query",
218
+ required: true,
219
+ schema: {type: "string"}
220
+ },
221
+ {
222
+ name: "callbackURL",
223
+ in: "query",
224
+ required: false,
225
+ schema: {type: "string"}
226
+ }
227
+ ],
228
+ responses: {
229
+ "200" => OpenAPI.json_response(
230
+ "User deleted",
231
+ OpenAPI.object_schema(
232
+ {
233
+ success: {type: "boolean"},
234
+ message: {type: "string"}
235
+ },
236
+ required: ["success", "message"]
237
+ )
238
+ )
239
+ }
240
+ }
241
+ }
242
+ ) do |ctx|
101
243
  enabled = ctx.context.options.user.dig(:delete_user, :enabled)
102
244
  raise APIError.new("NOT_FOUND") unless enabled
103
245
  session = current_session(ctx)
104
246
  token = fetch_value(ctx.query, "token")
105
- delete_user_by_token!(ctx, session, token)
106
247
  callback_url = fetch_value(ctx.query, "callbackURL")
107
248
  validate_callback_url!(ctx.context, callback_url)
249
+ delete_user_by_token!(ctx, session, token)
108
250
  delete_current_user!(ctx, session)
109
251
  raise ctx.redirect(callback_url) if callback_url
110
252
 
@@ -113,7 +255,43 @@ module BetterAuth
113
255
  end
114
256
 
115
257
  def self.change_email
116
- Endpoint.new(path: "/change-email", method: "POST") do |ctx|
258
+ Endpoint.new(
259
+ path: "/change-email",
260
+ method: "POST",
261
+ metadata: {
262
+ openapi: {
263
+ operationId: "changeEmail",
264
+ requestBody: OpenAPI.json_request_body(
265
+ OpenAPI.object_schema(
266
+ {
267
+ callbackURL: {type: ["string", "null"], description: "The URL to redirect to after email verification"},
268
+ newEmail: {type: "string", description: "The new email address to set must be a valid email address"}
269
+ },
270
+ required: ["newEmail"]
271
+ )
272
+ ),
273
+ responses: {
274
+ "200" => OpenAPI.json_response(
275
+ "Email change request processed successfully",
276
+ OpenAPI.object_schema(
277
+ {
278
+ message: {
279
+ type: "string",
280
+ nullable: true,
281
+ enum: ["Email updated", "Verification email sent"],
282
+ description: "Status message of the email change process"
283
+ },
284
+ status: {type: "boolean", description: "Indicates if the request was successful"},
285
+ user: {type: "object", "$ref": "#/components/schemas/User"}
286
+ },
287
+ required: ["status"]
288
+ )
289
+ ),
290
+ "422" => OpenAPI.error_response("Unprocessable Entity. Email already exists")
291
+ }
292
+ }
293
+ }
294
+ ) do |ctx|
117
295
  enabled = ctx.context.options.user.dig(:change_email, :enabled)
118
296
  raise APIError.new("BAD_REQUEST", message: "Change email is disabled") unless enabled
119
297
  session = current_session(ctx, sensitive: true)
@@ -121,26 +299,48 @@ module BetterAuth
121
299
  new_email = (body["newEmail"] || body["new_email"]).to_s.downcase
122
300
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless EMAIL_PATTERN.match?(new_email)
123
301
  raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == session[:user]["email"]
124
- existing_target = ctx.context.internal_adapter.find_user_by_email(new_email)
302
+ sender = ctx.context.options.email_verification[:send_verification_email]
303
+ confirmation_sender = ctx.context.options.user.dig(:change_email, :send_change_email_confirmation)
304
+ can_update_without_verification = !session[:user]["emailVerified"] && ctx.context.options.user.dig(:change_email, :update_email_without_verification)
305
+ can_send_confirmation = session[:user]["emailVerified"] && confirmation_sender.respond_to?(:call)
306
+ can_send_verification = sender.respond_to?(:call)
307
+ unless can_update_without_verification || can_send_confirmation || can_send_verification
308
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"])
309
+ end
125
310
 
126
- if !session[:user]["emailVerified"] && ctx.context.options.user.dig(:change_email, :update_email_without_verification)
127
- next ctx.json({status: true}) if existing_target
311
+ existing_target = ctx.context.internal_adapter.find_user_by_email(new_email)
312
+ next ctx.json({status: true}) if existing_target
128
313
 
314
+ if can_update_without_verification
129
315
  updated = ctx.context.internal_adapter.update_user_by_email(session[:user]["email"], email: new_email)
130
316
  Cookies.set_session_cookie(ctx, {session: session[:session], user: updated})
317
+ send_verification_email_payload(ctx, updated, body["callbackURL"] || body["callbackUrl"] || body["callback_url"]) if can_send_verification
131
318
  next ctx.json({status: true})
132
319
  end
133
320
 
134
- sender = ctx.context.options.email_verification[:send_verification_email]
135
- raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call)
136
- next ctx.json({status: true}) if existing_target
321
+ if can_send_confirmation
322
+ callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
323
+ token = create_email_verification_token(ctx, session[:user]["email"], update_to: new_email, extra: {"requestType" => "change-email-confirmation"})
324
+ url = email_verification_url(ctx, token, callback_url)
325
+ confirmation_sender.call({user: session[:user], new_email: new_email, url: url, token: token}, ctx.request)
326
+ next ctx.json({status: true})
327
+ end
137
328
 
138
- token = create_email_verification_token(ctx, session[:user]["email"], update_to: new_email, extra: {"requestType" => "change-email-verification"})
139
- sender.call({user: session[:user].merge("email" => new_email), token: token}, ctx.request)
329
+ send_change_email_verification(ctx, sender, session[:user], session[:user]["email"], new_email, body["callbackURL"] || body["callbackUrl"] || body["callback_url"])
140
330
  ctx.json({status: true})
141
331
  end
142
332
  end
143
333
 
334
+ def self.send_change_email_verification(ctx, sender, user, current_email, new_email, callback_url)
335
+ token = create_email_verification_token(ctx, current_email, update_to: new_email, extra: {"requestType" => "change-email-verification"})
336
+ sender.call({user: user.merge("email" => new_email), url: email_verification_url(ctx, token, callback_url), token: token}, ctx.request)
337
+ end
338
+
339
+ def self.email_verification_url(ctx, token, callback_url)
340
+ callback = URI.encode_www_form_component(callback_url || "/")
341
+ "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
342
+ end
343
+
144
344
  def self.delete_user_by_token!(ctx, session, token)
145
345
  verification = ctx.context.internal_adapter.find_verification_value("delete-account-#{token}")
146
346
  unless verification && verification["value"] == session[:user]["id"] && !expired_time?(verification["expiresAt"])
@@ -164,8 +364,10 @@ module BetterAuth
164
364
  fresh_age = ctx.context.session_config[:fresh_age].to_i
165
365
  return if fresh_age <= 0
166
366
 
167
- updated_at = Session.normalize_time(session[:session]["updatedAt"] || session[:session]["updated_at"] || session[:session]["createdAt"] || session[:session]["created_at"])
168
- raise APIError.new("UNAUTHORIZED") unless updated_at && updated_at + fresh_age > Time.now
367
+ created_at = Session.normalize_time(session[:session]["createdAt"] || session[:session]["created_at"])
368
+ return if created_at && created_at + fresh_age > Time.now
369
+
370
+ raise APIError.new("BAD_REQUEST", code: "SESSION_EXPIRED", message: BASE_ERROR_CODES["SESSION_EXPIRED"])
169
371
  end
170
372
 
171
373
  def self.parse_declared_input(ctx, model, data, allowed_base: [])
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Routes
5
+ REQUEST_EMAIL_PATTERN = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
6
+
7
+ def self.request_body_schema(required_strings: [], required_nonempty_strings: [], email_strings: [], optional_strings: [])
8
+ ->(body) {
9
+ data = request_validation_hash(body)
10
+ return false unless required_strings.all? { |key| request_string?(data, key) }
11
+ return false unless required_nonempty_strings.all? { |key| request_string?(data, key) && !data[request_storage_key(key)].empty? }
12
+ return false unless email_strings.all? { |key| request_string?(data, key) && REQUEST_EMAIL_PATTERN.match?(data[request_storage_key(key)]) }
13
+ return false unless optional_strings.all? { |key| !data.key?(request_storage_key(key)) || request_string?(data, key) }
14
+
15
+ data
16
+ }
17
+ end
18
+
19
+ def self.request_query_schema(required_strings: [], optional_strings: [])
20
+ ->(query) {
21
+ data = request_validation_hash(query)
22
+ return false unless required_strings.all? { |key| request_string?(data, key) }
23
+ return false unless optional_strings.all? { |key| !data.key?(request_storage_key(key)) || request_string?(data, key) }
24
+
25
+ data
26
+ }
27
+ end
28
+
29
+ def self.request_validation_hash(value)
30
+ return {} unless value.is_a?(Hash)
31
+
32
+ value.each_with_object({}) do |(key, object_value), result|
33
+ result[request_storage_key(key)] = object_value
34
+ end
35
+ end
36
+
37
+ def self.request_string?(data, key)
38
+ data[request_storage_key(key)].is_a?(String)
39
+ end
40
+
41
+ def self.request_storage_key(key)
42
+ key.to_s
43
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
44
+ .tr("-", "_")
45
+ .downcase
46
+ .split("_")
47
+ .then { |parts| ([parts.first] + parts.drop(1).map(&:capitalize)).join }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ class SecretConfig
5
+ ENVELOPE_PREFIX = "$ba$"
6
+
7
+ attr_reader :keys, :current_version, :legacy_secret
8
+
9
+ def initialize(keys:, current_version:, legacy_secret: nil)
10
+ normalized_keys = keys.each_with_object({}) do |(version, value), result|
11
+ result[normalize_version!(version)] = value.to_s
12
+ end
13
+ @keys = normalized_keys.freeze
14
+ @current_version = normalize_version!(current_version)
15
+ @legacy_secret = legacy_secret unless legacy_secret.to_s.empty?
16
+ end
17
+
18
+ def current_secret
19
+ keys.fetch(current_version) do
20
+ raise Error, "Secret version #{current_version} not found in keys"
21
+ end
22
+ end
23
+
24
+ def all_secrets
25
+ entries = keys.map { |version, value| [version, value] }
26
+ entries << [-1, legacy_secret] if legacy_secret && !keys.value?(legacy_secret)
27
+ entries
28
+ end
29
+
30
+ def self.parse_env(value)
31
+ return nil if value.to_s.empty?
32
+
33
+ value.to_s.split(",").map do |entry|
34
+ entry = entry.strip
35
+ colon_index = entry.index(":")
36
+ raise Error, "Invalid BETTER_AUTH_SECRETS entry: \"#{entry}\". Expected format: \"<version>:<secret>\"" unless colon_index
37
+
38
+ version = entry[0...colon_index].strip
39
+ secret = entry[(colon_index + 1)..].to_s.strip
40
+ raise Error, "Empty secret value for version #{version} in BETTER_AUTH_SECRETS." if secret.empty?
41
+
42
+ {version: parse_version!(version, source: "BETTER_AUTH_SECRETS"), value: secret}
43
+ end
44
+ end
45
+
46
+ def self.validate_secrets!(secrets, logger: nil)
47
+ entries = Array(secrets)
48
+ raise Error, "`secrets` array must contain at least one entry." if entries.empty?
49
+
50
+ seen = {}
51
+ entries.each do |entry|
52
+ data = normalize_entry(entry)
53
+ version = parse_version!(data.fetch(:version), source: "`secrets`")
54
+ value = data.fetch(:value, nil).to_s
55
+ raise Error, "Empty secret value for version #{version} in `secrets`." if value.empty?
56
+ raise Error, "Duplicate version #{version} in `secrets`. Each version must be unique." if seen[version]
57
+
58
+ seen[version] = true
59
+ end
60
+
61
+ current = normalize_entry(entries.first)
62
+ current_version = parse_version!(current.fetch(:version), source: "`secrets`")
63
+ current_value = current.fetch(:value).to_s
64
+ warn(logger, "[better-auth] Warning: the current secret (version #{current_version}) should be at least 32 characters long for adequate security.") if current_value.length < 32
65
+ warn(logger, "[better-auth] Warning: the current secret appears low-entropy. Use a randomly generated secret for production.") if entropy(current_value) < 120
66
+ end
67
+
68
+ def self.build(secrets, legacy_secret, logger: nil)
69
+ validate_secrets!(secrets, logger: logger)
70
+ entries = Array(secrets).map { |entry| normalize_entry(entry) }
71
+ keys = entries.each_with_object({}) do |entry, result|
72
+ result[parse_version!(entry.fetch(:version), source: "`secrets`")] = entry.fetch(:value).to_s
73
+ end
74
+ current_version = parse_version!(entries.first.fetch(:version), source: "`secrets`")
75
+ legacy = (legacy_secret && legacy_secret != Configuration::DEFAULT_SECRET) ? legacy_secret : nil
76
+ new(keys: keys, current_version: current_version, legacy_secret: legacy)
77
+ end
78
+
79
+ def self.normalize_entry(entry)
80
+ raise Error, "Invalid `secrets` entry. Expected a hash with `version` and `value`." unless entry.is_a?(Hash)
81
+
82
+ entry.each_with_object({}) do |(key, value), result|
83
+ result[key.to_s.tr("-", "_").to_sym] = value
84
+ end
85
+ end
86
+
87
+ def self.parse_version!(value, source:)
88
+ text = value.to_s.strip
89
+ unless text.match?(/\A(?:0|[1-9]\d*)\z/)
90
+ raise Error, "Invalid version #{value} in #{source}. Version must be a non-negative integer."
91
+ end
92
+
93
+ text.to_i
94
+ end
95
+
96
+ def self.entropy(value)
97
+ unique = value.to_s.chars.uniq.length
98
+ return 0 if unique.zero?
99
+
100
+ Math.log2(unique**value.to_s.length)
101
+ end
102
+
103
+ def self.warn(logger, message)
104
+ if logger.respond_to?(:call)
105
+ logger.call(:warn, message)
106
+ elsif logger.respond_to?(:warn)
107
+ logger.warn(message)
108
+ end
109
+ end
110
+
111
+ def normalize_version!(version)
112
+ self.class.parse_version!(version, source: "`secrets`")
113
+ end
114
+ end
115
+ end
@@ -108,7 +108,7 @@ module BetterAuth
108
108
  update_age = if refresh_cache.is_a?(Hash)
109
109
  (refresh_cache[:update_age] || refresh_cache["updateAge"] || refresh_cache["update_age"]).to_i
110
110
  else
111
- (max_age * 0.8).to_i
111
+ (max_age * 0.2).to_i
112
112
  end
113
113
  updated_at = payload["updatedAt"].to_i
114
114
  updated_at.positive? && updated_at + (update_age * 1000) <= (Time.now.to_f * 1000).to_i
@@ -153,6 +153,7 @@ module BetterAuth
153
153
  def headers_from_source(source)
154
154
  return {} unless source
155
155
  return source.headers if source.respond_to?(:headers)
156
+ return rack_request_headers(source) if source.respond_to?(:get_header)
156
157
  return source if source.is_a?(Hash)
157
158
 
158
159
  {}
@@ -167,7 +168,9 @@ module BetterAuth
167
168
  end
168
169
 
169
170
  def source_url(source)
170
- source.url if source.respond_to?(:url)
171
+ return source.url if source.respond_to?(:url)
172
+
173
+ source.get_header("REQUEST_URI") if source.respond_to?(:get_header)
171
174
  end
172
175
 
173
176
  def dynamic_config?(config)
@@ -191,5 +194,13 @@ module BetterAuth
191
194
 
192
195
  port.to_i.between?(1, 65_535)
193
196
  end
197
+
198
+ def rack_request_headers(source)
199
+ {
200
+ "x-forwarded-host" => source.get_header("HTTP_X_FORWARDED_HOST"),
201
+ "x-forwarded-proto" => source.get_header("HTTP_X_FORWARDED_PROTO"),
202
+ "host" => source.get_header("HTTP_HOST")
203
+ }.compact
204
+ end
194
205
  end
195
206
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BetterAuth
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/better_auth.rb CHANGED
@@ -4,10 +4,12 @@ require_relative "better_auth/version"
4
4
  require_relative "better_auth/core"
5
5
  require_relative "better_auth/error"
6
6
  require_relative "better_auth/api_error"
7
+ require_relative "better_auth/secret_config"
7
8
  require_relative "better_auth/crypto"
8
9
  require_relative "better_auth/host"
9
10
  require_relative "better_auth/url_helpers"
10
11
  require_relative "better_auth/request_state"
12
+ require_relative "better_auth/response"
11
13
  require_relative "better_auth/async"
12
14
  require_relative "better_auth/deprecate"
13
15
  require_relative "better_auth/logger"
@@ -48,6 +50,7 @@ require_relative "better_auth/plugins/one_time_token"
48
50
  require_relative "better_auth/plugins/one_tap"
49
51
  require_relative "better_auth/plugins/siwe"
50
52
  require_relative "better_auth/plugins/generic_oauth"
53
+ require_relative "better_auth/plugins/dub"
51
54
  require_relative "better_auth/plugins/oauth_proxy"
52
55
  require_relative "better_auth/plugins/passkey"
53
56
  require_relative "better_auth/plugins/organization/schema"
@@ -76,6 +79,7 @@ require_relative "better_auth/session_store"
76
79
  require_relative "better_auth/cookies"
77
80
  require_relative "better_auth/session"
78
81
  require_relative "better_auth/endpoint"
82
+ require_relative "better_auth/routes/validation"
79
83
  require_relative "better_auth/routes/ok"
80
84
  require_relative "better_auth/routes/error"
81
85
  require_relative "better_auth/routes/sign_up"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala
@@ -300,6 +300,7 @@ files:
300
300
  - lib/better_auth/plugins/captcha.rb
301
301
  - lib/better_auth/plugins/custom_session.rb
302
302
  - lib/better_auth/plugins/device_authorization.rb
303
+ - lib/better_auth/plugins/dub.rb
303
304
  - lib/better_auth/plugins/email_otp.rb
304
305
  - lib/better_auth/plugins/expo.rb
305
306
  - lib/better_auth/plugins/generic_oauth.rb
@@ -308,6 +309,16 @@ files:
308
309
  - lib/better_auth/plugins/last_login_method.rb
309
310
  - lib/better_auth/plugins/magic_link.rb
310
311
  - lib/better_auth/plugins/mcp.rb
312
+ - lib/better_auth/plugins/mcp/authorization.rb
313
+ - lib/better_auth/plugins/mcp/config.rb
314
+ - lib/better_auth/plugins/mcp/consent.rb
315
+ - lib/better_auth/plugins/mcp/legacy_aliases.rb
316
+ - lib/better_auth/plugins/mcp/metadata.rb
317
+ - lib/better_auth/plugins/mcp/registration.rb
318
+ - lib/better_auth/plugins/mcp/resource_handler.rb
319
+ - lib/better_auth/plugins/mcp/schema.rb
320
+ - lib/better_auth/plugins/mcp/token.rb
321
+ - lib/better_auth/plugins/mcp/userinfo.rb
311
322
  - lib/better_auth/plugins/multi_session.rb
312
323
  - lib/better_auth/plugins/oauth_protocol.rb
313
324
  - lib/better_auth/plugins/oauth_provider.rb
@@ -329,6 +340,7 @@ files:
329
340
  - lib/better_auth/rate_limiter.rb
330
341
  - lib/better_auth/request_ip.rb
331
342
  - lib/better_auth/request_state.rb
343
+ - lib/better_auth/response.rb
332
344
  - lib/better_auth/router.rb
333
345
  - lib/better_auth/routes/account.rb
334
346
  - lib/better_auth/routes/email_verification.rb
@@ -341,8 +353,10 @@ files:
341
353
  - lib/better_auth/routes/sign_up.rb
342
354
  - lib/better_auth/routes/social.rb
343
355
  - lib/better_auth/routes/user.rb
356
+ - lib/better_auth/routes/validation.rb
344
357
  - lib/better_auth/schema.rb
345
358
  - lib/better_auth/schema/sql.rb
359
+ - lib/better_auth/secret_config.rb
346
360
  - lib/better_auth/session.rb
347
361
  - lib/better_auth/session_store.rb
348
362
  - lib/better_auth/social_providers.rb