better_auth 0.8.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +142 -95
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +303 -54
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +390 -63
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +48 -47
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +119 -15
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +108 -0
  18. data/lib/better_auth/endpoint.rb +88 -5
  19. data/lib/better_auth/error.rb +3 -1
  20. data/lib/better_auth/http_client.rb +46 -0
  21. data/lib/better_auth/logger.rb +1 -1
  22. data/lib/better_auth/middleware/origin_check.rb +9 -1
  23. data/lib/better_auth/migration_plan.rb +15 -0
  24. data/lib/better_auth/oauth2.rb +1 -1
  25. data/lib/better_auth/oauth_state.rb +147 -0
  26. data/lib/better_auth/plugin_loader.rb +220 -0
  27. data/lib/better_auth/plugins/access.rb +10 -13
  28. data/lib/better_auth/plugins/admin.rb +85 -29
  29. data/lib/better_auth/plugins/anonymous.rb +2 -0
  30. data/lib/better_auth/plugins/api_key.rb +4 -7
  31. data/lib/better_auth/plugins/bearer.rb +8 -22
  32. data/lib/better_auth/plugins/captcha.rb +24 -6
  33. data/lib/better_auth/plugins/device_authorization.rb +77 -5
  34. data/lib/better_auth/plugins/dub.rb +10 -2
  35. data/lib/better_auth/plugins/email_otp.rb +30 -47
  36. data/lib/better_auth/plugins/generic_oauth.rb +162 -140
  37. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  38. data/lib/better_auth/plugins/i18n.rb +215 -0
  39. data/lib/better_auth/plugins/jwt.rb +44 -19
  40. data/lib/better_auth/plugins/magic_link.rb +18 -21
  41. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  42. data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
  43. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  44. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  45. data/lib/better_auth/plugins/one_tap.rb +78 -65
  46. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  47. data/lib/better_auth/plugins/open_api.rb +77 -9
  48. data/lib/better_auth/plugins/organization.rb +546 -102
  49. data/lib/better_auth/plugins/passkey.rb +4 -7
  50. data/lib/better_auth/plugins/phone_number.rb +25 -27
  51. data/lib/better_auth/plugins/scim.rb +4 -7
  52. data/lib/better_auth/plugins/siwe.rb +126 -16
  53. data/lib/better_auth/plugins/sso.rb +4 -7
  54. data/lib/better_auth/plugins/stripe.rb +4 -7
  55. data/lib/better_auth/plugins/two_factor.rb +205 -19
  56. data/lib/better_auth/plugins/username.rb +56 -13
  57. data/lib/better_auth/plugins.rb +57 -0
  58. data/lib/better_auth/rate_limiter.rb +300 -133
  59. data/lib/better_auth/request_ip.rb +123 -13
  60. data/lib/better_auth/router.rb +3 -3
  61. data/lib/better_auth/routes/account.rb +44 -8
  62. data/lib/better_auth/routes/email_verification.rb +93 -6
  63. data/lib/better_auth/routes/password.rb +6 -6
  64. data/lib/better_auth/routes/session.rb +7 -2
  65. data/lib/better_auth/routes/sign_in.rb +10 -1
  66. data/lib/better_auth/routes/sign_up.rb +45 -22
  67. data/lib/better_auth/routes/social.rb +197 -54
  68. data/lib/better_auth/routes/user.rb +17 -10
  69. data/lib/better_auth/schema/sql.rb +189 -34
  70. data/lib/better_auth/schema.rb +159 -5
  71. data/lib/better_auth/session.rb +29 -10
  72. data/lib/better_auth/session_store.rb +1 -1
  73. data/lib/better_auth/social_providers/apple.rb +6 -1
  74. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  75. data/lib/better_auth/social_providers/base.rb +45 -23
  76. data/lib/better_auth/social_providers/cognito.rb +75 -5
  77. data/lib/better_auth/social_providers/facebook.rb +95 -1
  78. data/lib/better_auth/social_providers/figma.rb +2 -0
  79. data/lib/better_auth/social_providers/github.rb +2 -0
  80. data/lib/better_auth/social_providers/google.rb +12 -1
  81. data/lib/better_auth/social_providers/line.rb +35 -1
  82. data/lib/better_auth/social_providers/linear.rb +1 -0
  83. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  84. data/lib/better_auth/social_providers/naver.rb +24 -1
  85. data/lib/better_auth/social_providers/notion.rb +1 -0
  86. data/lib/better_auth/social_providers/paybin.rb +25 -1
  87. data/lib/better_auth/social_providers/paypal.rb +118 -2
  88. data/lib/better_auth/social_providers/reddit.rb +25 -4
  89. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  90. data/lib/better_auth/social_providers/twitch.rb +24 -1
  91. data/lib/better_auth/social_providers/twitter.rb +36 -1
  92. data/lib/better_auth/social_providers/vk.rb +34 -1
  93. data/lib/better_auth/social_providers/wechat.rb +6 -4
  94. data/lib/better_auth/sql_migration.rb +566 -0
  95. data/lib/better_auth/version.rb +1 -1
  96. data/lib/better_auth.rb +14 -41
  97. metadata +14 -18
  98. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  99. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  100. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  101. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  102. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  103. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  104. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  105. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  106. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  107. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  108. data/lib/better_auth/plugins/mcp.rb +0 -215
  109. data/lib/better_auth/plugins/oidc_provider.rb +0 -710
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ # Maps `BetterAuth::Plugins` factory methods and nested modules to source files.
6
+ PLUGIN_FILES = {
7
+ additional_fields: "plugins/additional_fields",
8
+ custom_session: "plugins/custom_session",
9
+ multi_session: "plugins/multi_session",
10
+ last_login_method: "plugins/last_login_method",
11
+ bearer: "plugins/bearer",
12
+ jwt: "plugins/jwt",
13
+ open_api: "plugins/open_api",
14
+ access: "plugins/access",
15
+ username: "plugins/username",
16
+ anonymous: "plugins/anonymous",
17
+ magic_link: "plugins/magic_link",
18
+ email_otp: "plugins/email_otp",
19
+ phone_number: "plugins/phone_number",
20
+ one_time_token: "plugins/one_time_token",
21
+ one_tap: "plugins/one_tap",
22
+ siwe: "plugins/siwe",
23
+ generic_oauth: "plugins/generic_oauth",
24
+ oauth_popup: "plugins/oauth_popup",
25
+ dub: "plugins/dub",
26
+ oauth_proxy: "plugins/oauth_proxy",
27
+ passkey: "plugins/passkey",
28
+ organization_schema: "plugins/organization/schema",
29
+ organization: "plugins/organization",
30
+ admin_schema: "plugins/admin/schema",
31
+ admin: "plugins/admin",
32
+ oauth_protocol: "plugins/oauth_protocol",
33
+ oauth_provider: "plugins/oauth_provider",
34
+ device_authorization: "plugins/device_authorization",
35
+ two_factor: "plugins/two_factor",
36
+ captcha: "plugins/captcha",
37
+ have_i_been_pwned: "plugins/have_i_been_pwned",
38
+ api_key: "plugins/api_key",
39
+ sso: "plugins/sso",
40
+ scim: "plugins/scim",
41
+ stripe: "plugins/stripe",
42
+ expo: "plugins/expo",
43
+ i18n: "plugins/i18n"
44
+ }.freeze
45
+
46
+ PLUGIN_DEPENDENCIES = {
47
+ organization: %i[organization_schema access],
48
+ admin_schema: %i[organization_schema],
49
+ admin: %i[admin_schema access],
50
+ device_authorization: %i[oauth_protocol]
51
+ }.freeze
52
+
53
+ # Core route metadata helpers; loaded at boot because base endpoints reference OpenAPI.
54
+ BOOT_PLUGINS = %i[open_api].freeze
55
+
56
+ PLUGIN_ID_TO_LOADER = {
57
+ "additional-fields" => :additional_fields,
58
+ "custom-session" => :custom_session,
59
+ "multi-session" => :multi_session,
60
+ "last-login-method" => :last_login_method,
61
+ "bearer" => :bearer,
62
+ "jwt" => :jwt,
63
+ "open-api" => :open_api,
64
+ "username" => :username,
65
+ "anonymous" => :anonymous,
66
+ "magic-link" => :magic_link,
67
+ "email-otp" => :email_otp,
68
+ "phone-number" => :phone_number,
69
+ "one-time-token" => :one_time_token,
70
+ "one-tap" => :one_tap,
71
+ "siwe" => :siwe,
72
+ "generic-oauth" => :generic_oauth,
73
+ "oauth-popup" => :oauth_popup,
74
+ "dub" => :dub,
75
+ "oauth-proxy" => :oauth_proxy,
76
+ "passkey" => :passkey,
77
+ "organization" => :organization,
78
+ "admin" => :admin,
79
+ "oauth-provider" => :oauth_provider,
80
+ "device-authorization" => :device_authorization,
81
+ "two-factor" => :two_factor,
82
+ "captcha" => :captcha,
83
+ "have-i-been-pwned" => :have_i_been_pwned,
84
+ "api-key" => :api_key,
85
+ "sso" => :sso,
86
+ "scim" => :scim,
87
+ "stripe" => :stripe,
88
+ "expo" => :expo,
89
+ "i18n" => :i18n
90
+ }.freeze
91
+
92
+ NESTED_MODULE_LOADERS = {
93
+ OAuthProtocol: :oauth_protocol,
94
+ JWT: :jwt,
95
+ OrganizationSchema: :organization_schema,
96
+ AdminSchema: :admin_schema
97
+ }.freeze
98
+
99
+ LAZY_PLUGIN_METHODS = PLUGIN_FILES.keys.freeze
100
+
101
+ @loaded_plugins = {}
102
+
103
+ EXTERNAL_PLUGIN_IMPLEMENTATIONS = {
104
+ sso: :SSO_PLUGIN_IMPLEMENTATION,
105
+ scim: :SCIM_PLUGIN_IMPLEMENTATION,
106
+ api_key: :API_KEY_PLUGIN_IMPLEMENTATION,
107
+ passkey: :PASSKEY_PLUGIN_IMPLEMENTATION,
108
+ stripe: :STRIPE_PLUGIN_IMPLEMENTATION,
109
+ oauth_provider: :OAUTH_PROVIDER_PLUGIN_IMPLEMENTATION
110
+ }.freeze
111
+
112
+ module_function
113
+
114
+ def ensure_external_plugin_loaded!(gem_name:, entry:, implementation_constant:)
115
+ return if const_defined?(implementation_constant, false)
116
+
117
+ spec = Gem.loaded_specs[gem_name] || Gem::Specification.find_by_name(gem_name)
118
+ entry_path = File.join(spec.full_gem_path, entry)
119
+ load entry_path unless $LOADED_FEATURES.include?(entry_path)
120
+
121
+ return if const_defined?(implementation_constant, false)
122
+
123
+ raise LoadError,
124
+ "BetterAuth requires the #{gem_name} gem. Add it to your Gemfile and require its entrypoint."
125
+ rescue Gem::MissingSpecError
126
+ raise LoadError,
127
+ "BetterAuth requires the #{gem_name} gem. Add it to your Gemfile and require its entrypoint."
128
+ end
129
+
130
+ def call_external_plugin!(method_name, *args, implementation_constant:, gem_name:, entry:, &block)
131
+ loader = method(method_name)
132
+ ensure_external_plugin_loaded!(
133
+ gem_name: gem_name,
134
+ entry: entry,
135
+ implementation_constant: implementation_constant
136
+ )
137
+ resolved = method(method_name)
138
+ if resolved == loader
139
+ raise LoadError,
140
+ "BetterAuth::Plugins.#{method_name} requires the #{gem_name} gem. Add it to your Gemfile and require its entrypoint."
141
+ end
142
+
143
+ resolved.call(*args, &block)
144
+ end
145
+
146
+ def load_plugin!(name)
147
+ name = name.to_sym
148
+ return true if @loaded_plugins[name]
149
+ implementation_constant = EXTERNAL_PLUGIN_IMPLEMENTATIONS[name]
150
+ if implementation_constant && const_defined?(implementation_constant, false)
151
+ @loaded_plugins[name] = true
152
+ return true
153
+ end
154
+
155
+ Array(PLUGIN_DEPENDENCIES[name]).each { |dependency| load_plugin!(dependency) }
156
+
157
+ relative_path = PLUGIN_FILES.fetch(name) do
158
+ raise ArgumentError, "Unknown plugin loader: #{name}"
159
+ end
160
+
161
+ absolute_path = File.expand_path(relative_path, __dir__)
162
+ unless File.file?("#{absolute_path}.rb")
163
+ @loaded_plugins[name] = true
164
+ return false
165
+ end
166
+
167
+ require_relative relative_path
168
+ @loaded_plugins[name] = true
169
+ true
170
+ end
171
+
172
+ def plugin_loaded?(name)
173
+ @loaded_plugins.key?(name.to_sym)
174
+ end
175
+
176
+ def ensure_plugin_loaded_for!(plugin)
177
+ return unless plugin.is_a?(BetterAuth::Plugin)
178
+
179
+ loader = PLUGIN_ID_TO_LOADER[plugin.id]
180
+ load_plugin!(loader) if loader
181
+ end
182
+
183
+ def lazy_plugin_method?(name)
184
+ !plugin_loader_for_method(name).nil?
185
+ end
186
+
187
+ def plugin_loader_for_method(name)
188
+ symbol = name.to_sym
189
+ return symbol if LAZY_PLUGIN_METHODS.include?(symbol)
190
+
191
+ PLUGIN_FILES.each_key do |plugin|
192
+ return plugin if name.to_s.start_with?("#{plugin}_")
193
+ end
194
+
195
+ nil
196
+ end
197
+
198
+ def load_plugin_for_constant!(name)
199
+ if (loader = NESTED_MODULE_LOADERS[name])
200
+ return load_plugin!(loader)
201
+ end
202
+
203
+ if name == :OpenAPI
204
+ return load_plugin!(:open_api)
205
+ end
206
+
207
+ constant = name.to_s
208
+ if constant.end_with?("_ERROR_CODES")
209
+ plugin_name = constant.delete_suffix("_ERROR_CODES").downcase.to_sym
210
+ return load_plugin!(plugin_name) if PLUGIN_FILES.key?(plugin_name)
211
+ end
212
+
213
+ false
214
+ end
215
+
216
+ def load_boot_plugins!
217
+ BOOT_PLUGINS.each { |plugin| load_plugin!(plugin) }
218
+ end
219
+ end
220
+ end
@@ -10,36 +10,33 @@ module BetterAuth
10
10
  end
11
11
 
12
12
  def authorize(request, connector = "AND")
13
+ connector = (connector == "OR") ? "OR" : "AND"
13
14
  success = false
14
15
  stringify_request(request).each do |resource, requested_actions|
15
16
  allowed_actions = statements[resource]
16
17
  unless allowed_actions
18
+ next if connector == "OR"
19
+
17
20
  return {success: false, error: "You are not allowed to access resource: #{resource}"}
18
21
  end
19
22
 
20
23
  success = if requested_actions.is_a?(Array)
21
- requested_actions.all? { |action| allowed_actions.include?(action.to_s) }
24
+ requested_actions.any? && requested_actions.all? { |action| action.is_a?(String) && allowed_actions.include?(action) }
22
25
  elsif requested_actions.is_a?(Hash)
23
- unless requested_actions.key?("actions") || requested_actions.key?(:actions)
24
- raise Error, "Invalid access control request"
25
- end
26
-
27
26
  raw_actions = requested_actions["actions"] || requested_actions[:actions]
28
- raise Error, "Invalid access control request" if raw_actions.nil?
29
-
30
- actions = Array(raw_actions).map(&:to_s)
31
- action_connector = (requested_actions["connector"] || requested_actions[:connector] || "AND").to_s.upcase
27
+ actions = raw_actions.is_a?(Array) ? raw_actions : []
28
+ action_connector = ((requested_actions["connector"] || requested_actions[:connector]) == "OR") ? "OR" : "AND"
32
29
  if action_connector == "OR"
33
- actions.any? { |action| allowed_actions.include?(action) }
30
+ actions.any? { |action| action.is_a?(String) && allowed_actions.include?(action) }
34
31
  else
35
- actions.all? { |action| allowed_actions.include?(action) }
32
+ actions.any? && actions.all? { |action| action.is_a?(String) && allowed_actions.include?(action) }
36
33
  end
37
34
  else
38
35
  raise Error, "Invalid access control request"
39
36
  end
40
37
 
41
- return {success: true} if success && connector.to_s.upcase == "OR"
42
- return {success: false, error: "unauthorized to access resource \"#{resource}\""} if !success && connector.to_s.upcase == "AND"
38
+ return {success: true} if success && connector == "OR"
39
+ return {success: false, error: "unauthorized to access resource \"#{resource}\""} if !success && connector == "AND"
43
40
  end
44
41
 
45
42
  success ? {success: true} : {success: false, error: "Not authorized"}
@@ -25,15 +25,17 @@ module BetterAuth
25
25
  "YOU_CANNOT_REMOVE_YOURSELF" => "You cannot remove yourself",
26
26
  "YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE" => "You are not allowed to set a non-existent role value",
27
27
  "YOU_CANNOT_IMPERSONATE_ADMINS" => "You cannot impersonate admins",
28
- "INVALID_ROLE_TYPE" => "Invalid role type"
28
+ "INVALID_ROLE_TYPE" => "Invalid role type",
29
+ "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL" => "You are not allowed to update users email",
30
+ "PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER" => "Password cannot be updated through update-user. Use the set-user-password endpoint instead"
29
31
  }.freeze
30
32
 
31
33
  ADMIN_DEFAULT_STATEMENTS = {
32
- user: ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "get", "update"],
34
+ user: ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"],
33
35
  session: ["list", "revoke", "delete"]
34
36
  }.freeze
35
37
  ADMIN_DEFAULT_ROLE_STATEMENTS = {
36
- user: ["create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "get", "update"],
38
+ user: ["create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "set-email", "get", "update"],
37
39
  session: ["list", "revoke", "delete"]
38
40
  }.freeze
39
41
 
@@ -66,7 +68,11 @@ module BetterAuth
66
68
  after: [
67
69
  {
68
70
  matcher: ->(ctx) { ctx.path == "/list-sessions" },
69
- handler: ->(ctx) { ctx.json(Array(ctx.returned).reject { |session| session["impersonatedBy"] || session[:impersonatedBy] }) }
71
+ handler: lambda do |ctx|
72
+ next unless ctx.returned.is_a?(Array)
73
+
74
+ ctx.json(ctx.returned.reject { |session| session["impersonatedBy"] || session[:impersonatedBy] })
75
+ end
70
76
  }
71
77
  ]
72
78
  },
@@ -154,6 +160,8 @@ module BetterAuth
154
160
  user_id = body[:user_id].to_s
155
161
  raise APIError.new("BAD_REQUEST", message: "userId is required") if user_id.empty?
156
162
  update = {role: admin_validate_roles!(body[:role], config)}
163
+ raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id)
164
+
157
165
  user = ctx.context.internal_adapter.update_user(user_id, update)
158
166
  ctx.json({user: Schema.parse_output(ctx.context.options, "user", user || {})})
159
167
  end
@@ -185,7 +193,7 @@ module BetterAuth
185
193
  elsif query[:email]
186
194
  ctx.context.internal_adapter.find_user_by_email(query[:email])&.fetch(:user)
187
195
  end
188
- raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless user
196
+ raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless user
189
197
  ctx.json(Schema.parse_output(ctx.context.options, "user", user))
190
198
  end
191
199
  end
@@ -208,10 +216,10 @@ module BetterAuth
208
216
  required: ["email", "name"]
209
217
  )
210
218
  ) do |ctx|
211
- session = Routes.current_session(ctx, allow_nil: true)
219
+ session = Routes.current_session(ctx, allow_nil: true, sensitive: true)
212
220
  if session
213
221
  unless admin_permission?(session[:user], session[:user]["role"], {user: ["create"]}, config)
214
- raise APIError.new("FORBIDDEN", message: ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS"))
222
+ raise APIError.new("FORBIDDEN", code: "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS", message: ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS"))
215
223
  end
216
224
  elsif !ctx.headers.empty?
217
225
  raise APIError.new("UNAUTHORIZED")
@@ -219,18 +227,25 @@ module BetterAuth
219
227
 
220
228
  body = normalize_hash(ctx.body)
221
229
  email = body[:email].to_s.downcase
222
- raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("INVALID_EMAIL")) unless Routes::EMAIL_PATTERN.match?(email)
230
+ raise APIError.new("BAD_REQUEST", code: "INVALID_EMAIL", message: BASE_ERROR_CODES.fetch("INVALID_EMAIL")) unless Routes::EMAIL_PATTERN.match?(email)
223
231
 
224
232
  if ctx.context.internal_adapter.find_user_by_email(email)
225
- raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"))
233
+ raise APIError.new("BAD_REQUEST", code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL", message: ADMIN_ERROR_CODES.fetch("USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"))
226
234
  end
227
235
  data = normalize_hash(body[:data]).each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value }
236
+ requested_role = body.key?(:role) ? body[:role] : data.delete("role")
237
+ if !requested_role.nil? && session
238
+ admin_require_permission!(ctx, config, {user: ["set-role"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE"))
239
+ end
240
+ if session && ["banned", "banReason", "banExpires"].any? { |field| data.key?(field) }
241
+ admin_require_permission!(ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"))
242
+ end
228
243
  user = ctx.context.internal_adapter.create_user(data.merge(
229
244
  name: body[:name].to_s,
230
245
  email: email,
231
- role: admin_validate_roles!(body[:role] || config[:default_role], config)
246
+ role: requested_role.nil? ? config[:default_role] : admin_validate_roles!(requested_role, config)
232
247
  ).merge(body.key?(:image) ? {image: body[:image]} : {}), context: ctx)
233
- raise APIError.new("INTERNAL_SERVER_ERROR", message: ADMIN_ERROR_CODES.fetch("FAILED_TO_CREATE_USER")) unless user
248
+ raise APIError.new("INTERNAL_SERVER_ERROR", code: "FAILED_TO_CREATE_USER", message: ADMIN_ERROR_CODES.fetch("FAILED_TO_CREATE_USER")) unless user
234
249
 
235
250
  if body[:password].to_s != ""
236
251
  ctx.context.internal_adapter.link_account(userId: user["id"], providerId: "credential", accountId: user["id"], password: Routes.hash_password(ctx, body[:password]))
@@ -265,12 +280,38 @@ module BetterAuth
265
280
  admin_require_permission!(ctx, config, {user: ["update"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS"))
266
281
  body = normalize_hash(ctx.body)
267
282
  data = normalize_hash(body[:data] || body).except(:user_id, :data)
268
- raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("NO_DATA_TO_UPDATE")) if data.empty?
283
+ raise APIError.new("BAD_REQUEST", code: "NO_DATA_TO_UPDATE", message: ADMIN_ERROR_CODES.fetch("NO_DATA_TO_UPDATE")) if data.empty?
284
+ if data.key?(:password)
285
+ raise APIError.new("BAD_REQUEST", code: "PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER", message: ADMIN_ERROR_CODES.fetch("PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER"))
286
+ end
269
287
  if data.key?(:role)
270
288
  admin_require_permission!(ctx, config, {user: ["set-role"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE"))
271
289
  data[:role] = admin_validate_roles!(data[:role], config)
272
290
  end
273
- user = ctx.context.internal_adapter.update_user(body[:user_id], data)
291
+ if [:banned, :ban_reason, :ban_expires].any? { |field| data.key?(field) }
292
+ session = admin_require_permission!(ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"))
293
+ if data[:banned] == true && body[:user_id] == session[:user]["id"]
294
+ raise APIError.new("BAD_REQUEST", code: "YOU_CANNOT_BAN_YOURSELF", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_BAN_YOURSELF"))
295
+ end
296
+ end
297
+ if data.key?(:email) || data.key?(:email_verified)
298
+ admin_require_permission!(ctx, config, {user: ["set-email"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL"))
299
+ if data.key?(:email)
300
+ email = data[:email].to_s.downcase
301
+ raise APIError.new("BAD_REQUEST", code: "INVALID_EMAIL", message: BASE_ERROR_CODES.fetch("INVALID_EMAIL")) unless Routes::EMAIL_PATTERN.match?(email)
302
+
303
+ existing = ctx.context.internal_adapter.find_user_by_email(email)
304
+ if existing && existing[:user]["id"] != body[:user_id]
305
+ raise APIError.new("BAD_REQUEST", code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL", message: ADMIN_ERROR_CODES.fetch("USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"))
306
+ end
307
+ data[:email] = email
308
+ end
309
+ end
310
+ raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(body[:user_id])
311
+
312
+ stored_data = data.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value }
313
+ user = ctx.context.internal_adapter.update_user(body[:user_id], stored_data)
314
+ ctx.context.internal_adapter.delete_user_sessions(body[:user_id]) if data[:banned] == true
274
315
  ctx.json(Schema.parse_output(ctx.context.options, "user", user))
275
316
  end
276
317
  end
@@ -353,11 +394,11 @@ module BetterAuth
353
394
  session = admin_require_permission!(ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"))
354
395
  body = normalize_hash(ctx.body)
355
396
  found = ctx.context.internal_adapter.find_user_by_id(body[:user_id])
356
- raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless found
357
- raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_BAN_YOURSELF")) if body[:user_id] == session[:user]["id"]
397
+ raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless found
398
+ raise APIError.new("BAD_REQUEST", code: "YOU_CANNOT_BAN_YOURSELF", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_BAN_YOURSELF")) if body[:user_id] == session[:user]["id"]
358
399
  expires_in = body[:ban_expires_in] || config[:default_ban_expires_in]
359
400
  user = ctx.context.internal_adapter.update_user(body[:user_id], banned: true, banReason: body[:ban_reason] || config[:default_ban_reason] || "No reason", banExpires: expires_in ? Time.now + expires_in.to_i : nil, updatedAt: Time.now)
360
- ctx.context.internal_adapter.delete_sessions(body[:user_id])
401
+ ctx.context.internal_adapter.delete_user_sessions(body[:user_id])
361
402
  ctx.json({user: Schema.parse_output(ctx.context.options, "user", user)})
362
403
  end
363
404
  end
@@ -377,7 +418,10 @@ module BetterAuth
377
418
  )
378
419
  ) do |ctx|
379
420
  admin_require_permission!(ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"))
380
- user = ctx.context.internal_adapter.update_user(normalize_hash(ctx.body)[:user_id], banned: false, banReason: nil, banExpires: nil, updatedAt: Time.now)
421
+ user_id = normalize_hash(ctx.body)[:user_id]
422
+ raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id)
423
+
424
+ user = ctx.context.internal_adapter.update_user(user_id, banned: false, banReason: nil, banExpires: nil, updatedAt: Time.now)
381
425
  ctx.json({user: Schema.parse_output(ctx.context.options, "user", user)})
382
426
  end
383
427
  end
@@ -407,11 +451,11 @@ module BetterAuth
407
451
  session = admin_require_permission!(ctx, config, {user: ["impersonate"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS"))
408
452
  body = normalize_hash(ctx.body)
409
453
  target = ctx.context.internal_adapter.find_user_by_id(body[:user_id])
410
- raise APIError.new("NOT_FOUND", message: "User not found") unless target
454
+ raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless target
411
455
  can_impersonate_admins = config[:allow_impersonating_admins] ||
412
456
  admin_permission?(session[:user], session[:user]["role"], {user: ["impersonate-admins"]}, config)
413
457
  if !can_impersonate_admins && admin_user?(target, config)
414
- raise APIError.new("FORBIDDEN", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_IMPERSONATE_ADMINS"))
458
+ raise APIError.new("FORBIDDEN", code: "YOU_CANNOT_IMPERSONATE_ADMINS", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_IMPERSONATE_ADMINS"))
415
459
  end
416
460
  impersonated = ctx.context.internal_adapter.create_session(target["id"], true, {impersonatedBy: session[:user]["id"], expiresAt: Time.now + config[:impersonation_session_duration].to_i}, true, ctx)
417
461
  raise APIError.new("INTERNAL_SERVER_ERROR", message: ADMIN_ERROR_CODES.fetch("FAILED_TO_CREATE_USER")) unless impersonated
@@ -436,6 +480,7 @@ module BetterAuth
436
480
  openapi: {
437
481
  operationId: "stopImpersonating",
438
482
  description: "Stop impersonating a user",
483
+ requestBody: OpenAPI.empty_request_body,
439
484
  responses: {
440
485
  "200" => OpenAPI.json_response("Impersonation stopped", OpenAPI.session_response_schema_pair)
441
486
  }
@@ -519,7 +564,7 @@ module BetterAuth
519
564
  }
520
565
  ) do |ctx|
521
566
  admin_require_permission!(ctx, config, {session: ["revoke"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS"))
522
- ctx.context.internal_adapter.delete_sessions(normalize_hash(ctx.body)[:user_id])
567
+ ctx.context.internal_adapter.delete_user_sessions(normalize_hash(ctx.body)[:user_id])
523
568
  ctx.json({success: true})
524
569
  end
525
570
  end
@@ -548,8 +593,8 @@ module BetterAuth
548
593
  ) do |ctx|
549
594
  session = admin_require_permission!(ctx, config, {user: ["delete"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS"))
550
595
  user_id = normalize_hash(ctx.body)[:user_id]
551
- raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_REMOVE_YOURSELF")) if user_id == session[:user]["id"]
552
- raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id)
596
+ raise APIError.new("BAD_REQUEST", code: "YOU_CANNOT_REMOVE_YOURSELF", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_REMOVE_YOURSELF")) if user_id == session[:user]["id"]
597
+ raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id)
553
598
  ctx.context.internal_adapter.delete_user(user_id)
554
599
  ctx.json({success: true})
555
600
  end
@@ -585,9 +630,17 @@ module BetterAuth
585
630
  raise APIError.new("BAD_REQUEST", message: "userId is required") if user_id.empty?
586
631
  min = ctx.context.options.email_and_password[:min_password_length]
587
632
  max = ctx.context.options.email_and_password[:max_password_length]
588
- raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_SHORT")) if password.length < min
589
- raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_LONG")) if password.length > max
590
- ctx.context.internal_adapter.update_password(user_id, Routes.hash_password(ctx, password))
633
+ raise APIError.new("BAD_REQUEST", code: "PASSWORD_TOO_SHORT", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_SHORT")) if password.length < min
634
+ raise APIError.new("BAD_REQUEST", code: "PASSWORD_TOO_LONG", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_LONG")) if password.length > max
635
+ raise APIError.new("NOT_FOUND", code: "USER_NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless ctx.context.internal_adapter.find_user_by_id(user_id)
636
+
637
+ hashed_password = Routes.hash_password(ctx, password)
638
+ credential_account = ctx.context.internal_adapter.find_accounts(user_id).find { |account| account["providerId"] == "credential" }
639
+ if credential_account
640
+ ctx.context.internal_adapter.update_password(user_id, hashed_password)
641
+ else
642
+ ctx.context.internal_adapter.create_account(userId: user_id, providerId: "credential", accountId: user_id, password: hashed_password)
643
+ end
591
644
  ctx.json({status: true})
592
645
  end
593
646
  end
@@ -626,7 +679,7 @@ module BetterAuth
626
679
  }
627
680
  }
628
681
  ) do |ctx|
629
- session = Routes.current_session(ctx, allow_nil: true)
682
+ session = Routes.current_session(ctx, allow_nil: true, sensitive: true)
630
683
  body = normalize_hash(ctx.body)
631
684
  permissions = body[:permissions] || body[:permission]
632
685
  unless permissions
@@ -718,7 +771,10 @@ module BetterAuth
718
771
  session = Routes.current_session(ctx, sensitive: true)
719
772
  return session if admin_permission?(session[:user], session[:user]["role"], permissions, config)
720
773
 
721
- raise APIError.new("FORBIDDEN", message: message)
774
+ code = ADMIN_ERROR_CODES.key(message)
775
+ raise Error, "Unknown admin permission error: #{message}" unless code
776
+
777
+ raise APIError.new("FORBIDDEN", code: code, message: message)
722
778
  end
723
779
 
724
780
  def admin_permission?(user, role_string, permissions, config)
@@ -745,7 +801,7 @@ module BetterAuth
745
801
 
746
802
  def admin_validate_roles!(roles, config)
747
803
  unless Array(roles).all? { |role| role.is_a?(String) || role.is_a?(Symbol) }
748
- raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("INVALID_ROLE_TYPE"))
804
+ raise APIError.new("BAD_REQUEST", code: "INVALID_ROLE_TYPE", message: ADMIN_ERROR_CODES.fetch("INVALID_ROLE_TYPE"))
749
805
  end
750
806
 
751
807
  parsed = admin_parse_roles(roles)
@@ -753,7 +809,7 @@ module BetterAuth
753
809
  defined_roles = (config[:roles] || {}).transform_keys(&:to_s)
754
810
  invalid = parsed.split(",", -1).reject { |role| admin_role_for(defined_roles, role) }
755
811
  if invalid.any?
756
- raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE"))
812
+ raise APIError.new("BAD_REQUEST", code: "YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE", message: ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE"))
757
813
  end
758
814
  end
759
815
 
@@ -47,6 +47,7 @@ module BetterAuth
47
47
  openapi: {
48
48
  operationId: "signInAnonymous",
49
49
  description: "Sign in anonymously",
50
+ requestBody: OpenAPI.empty_request_body,
50
51
  responses: {
51
52
  "200" => OpenAPI.json_response(
52
53
  "Anonymous session created",
@@ -96,6 +97,7 @@ module BetterAuth
96
97
  openapi: {
97
98
  operationId: "deleteAnonymousUser",
98
99
  description: "Delete the current anonymous user",
100
+ requestBody: OpenAPI.empty_request_body,
99
101
  responses: {
100
102
  "200" => OpenAPI.json_response("Anonymous user deleted", OpenAPI.success_response_schema)
101
103
  }
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ return if defined?(BetterAuth::Plugins::API_KEY_PLUGIN_IMPLEMENTATION)
4
+
3
5
  module BetterAuth
4
6
  module Plugins
5
7
  module_function
6
8
 
7
- def api_key(*args)
8
- Kernel.require "better_auth/api_key"
9
- BetterAuth::Plugins.api_key(*args)
10
- rescue LoadError => error
11
- raise if error.path && error.path != "better_auth/api_key"
12
-
13
- raise LoadError, "BetterAuth::Plugins.api_key requires the better_auth-api-key gem. Add `gem \"better_auth-api-key\"` and `require \"better_auth/api_key\"`."
9
+ def api_key(*args, &block)
10
+ call_external_plugin!(:api_key, *args, implementation_constant: :API_KEY_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-api-key", entry: "lib/better_auth/api_key.rb", &block)
14
11
  end
15
12
  end
16
13
  end
@@ -50,7 +50,7 @@ module BetterAuth
50
50
  return unless signed_token && valid_signed_token?(ctx, signed_token)
51
51
 
52
52
  cookie_name = ctx.context.auth_cookies[:session_token].name
53
- cookie = [ctx.headers["cookie"], "#{cookie_name}=#{signed_token}"].compact.reject(&:empty?).join("; ")
53
+ cookie = Cookies.set_request_cookie(ctx.headers["cookie"], cookie_name, signed_token)
54
54
  {context: {headers: ctx.headers.merge("cookie" => cookie)}}
55
55
  end
56
56
 
@@ -86,37 +86,23 @@ module BetterAuth
86
86
  token.to_s
87
87
  end
88
88
 
89
- def bearer_session_cookie(line)
90
- first, *attributes = line.to_s.split(";").map(&:strip)
91
- name, value = first.split("=", 2)
92
- return unless name && value
93
-
94
- {
95
- name: name,
96
- value: value,
97
- attributes: attributes.each_with_object({}) do |attribute, result|
98
- key, attribute_value = attribute.split("=", 2)
99
- result[key.to_s.downcase] = attribute_value || true unless key.to_s.empty?
100
- end
101
- }
102
- end
103
-
104
89
  def expired_bearer_cookie?(cookie)
105
90
  max_age = cookie[:attributes]["max-age"]
106
91
  max_age.to_s.strip.match?(/\A[+-]?\d+\z/) && max_age.to_i == 0
107
92
  end
108
93
 
109
94
  def expose_auth_token(ctx)
110
- set_cookie = ctx.response_headers["set-cookie"].to_s
111
95
  token_name = ctx.context.auth_cookies[:session_token].name
112
- token = set_cookie.lines.filter_map do |line|
113
- cookie = bearer_session_cookie(line)
96
+ session_cookie = Cookies.split_set_cookie_header(ctx.response_headers["set-cookie"]).reverse_each.filter_map do |line|
97
+ cookie = Cookies.parse_set_cookie(line)
114
98
  next unless cookie && cookie[:name] == token_name
115
- next if cookie[:value].empty? || expired_bearer_cookie?(cookie)
116
99
 
117
- cookie[:value]
100
+ cookie
118
101
  end.first
119
- return unless token
102
+ return unless session_cookie
103
+ return if session_cookie[:value].empty? || expired_bearer_cookie?(session_cookie)
104
+
105
+ token = session_cookie[:value]
120
106
 
121
107
  exposed = ctx.response_headers["access-control-expose-headers"].to_s.split(",").map(&:strip).reject(&:empty?)
122
108
  exposed << "set-auth-token"