better_auth 0.10.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +138 -91
  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 +191 -56
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +294 -49
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +41 -46
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +110 -14
  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 +14 -3
  18. data/lib/better_auth/error.rb +3 -1
  19. data/lib/better_auth/logger.rb +1 -1
  20. data/lib/better_auth/middleware/origin_check.rb +9 -1
  21. data/lib/better_auth/migration_plan.rb +4 -4
  22. data/lib/better_auth/oauth_state.rb +147 -0
  23. data/lib/better_auth/plugin_loader.rb +220 -0
  24. data/lib/better_auth/plugins/access.rb +10 -13
  25. data/lib/better_auth/plugins/admin.rb +79 -28
  26. data/lib/better_auth/plugins/api_key.rb +4 -7
  27. data/lib/better_auth/plugins/bearer.rb +8 -22
  28. data/lib/better_auth/plugins/captcha.rb +24 -6
  29. data/lib/better_auth/plugins/device_authorization.rb +47 -9
  30. data/lib/better_auth/plugins/dub.rb +2 -2
  31. data/lib/better_auth/plugins/email_otp.rb +30 -47
  32. data/lib/better_auth/plugins/generic_oauth.rb +141 -146
  33. data/lib/better_auth/plugins/i18n.rb +215 -0
  34. data/lib/better_auth/plugins/jwt.rb +37 -19
  35. data/lib/better_auth/plugins/magic_link.rb +18 -21
  36. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  37. data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
  38. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  39. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  40. data/lib/better_auth/plugins/one_tap.rb +61 -60
  41. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  42. data/lib/better_auth/plugins/open_api.rb +35 -7
  43. data/lib/better_auth/plugins/organization.rb +455 -122
  44. data/lib/better_auth/plugins/passkey.rb +4 -7
  45. data/lib/better_auth/plugins/phone_number.rb +24 -26
  46. data/lib/better_auth/plugins/scim.rb +4 -7
  47. data/lib/better_auth/plugins/siwe.rb +126 -16
  48. data/lib/better_auth/plugins/sso.rb +4 -7
  49. data/lib/better_auth/plugins/stripe.rb +4 -7
  50. data/lib/better_auth/plugins/two_factor.rb +186 -21
  51. data/lib/better_auth/plugins/username.rb +56 -13
  52. data/lib/better_auth/plugins.rb +57 -0
  53. data/lib/better_auth/rate_limiter.rb +297 -135
  54. data/lib/better_auth/request_ip.rb +123 -13
  55. data/lib/better_auth/router.rb +3 -3
  56. data/lib/better_auth/routes/account.rb +40 -8
  57. data/lib/better_auth/routes/email_verification.rb +88 -5
  58. data/lib/better_auth/routes/password.rb +5 -6
  59. data/lib/better_auth/routes/session.rb +7 -2
  60. data/lib/better_auth/routes/sign_in.rb +10 -1
  61. data/lib/better_auth/routes/sign_up.rb +45 -22
  62. data/lib/better_auth/routes/social.rb +169 -54
  63. data/lib/better_auth/routes/user.rb +11 -8
  64. data/lib/better_auth/schema/sql.rb +90 -24
  65. data/lib/better_auth/schema.rb +124 -3
  66. data/lib/better_auth/session.rb +27 -9
  67. data/lib/better_auth/session_store.rb +1 -1
  68. data/lib/better_auth/social_providers/apple.rb +6 -1
  69. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  70. data/lib/better_auth/social_providers/base.rb +41 -14
  71. data/lib/better_auth/social_providers/cognito.rb +75 -5
  72. data/lib/better_auth/social_providers/facebook.rb +95 -1
  73. data/lib/better_auth/social_providers/figma.rb +2 -0
  74. data/lib/better_auth/social_providers/google.rb +12 -1
  75. data/lib/better_auth/social_providers/line.rb +35 -1
  76. data/lib/better_auth/social_providers/linear.rb +1 -0
  77. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  78. data/lib/better_auth/social_providers/naver.rb +24 -1
  79. data/lib/better_auth/social_providers/notion.rb +1 -0
  80. data/lib/better_auth/social_providers/paybin.rb +25 -1
  81. data/lib/better_auth/social_providers/paypal.rb +118 -2
  82. data/lib/better_auth/social_providers/reddit.rb +25 -4
  83. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  84. data/lib/better_auth/social_providers/twitch.rb +24 -1
  85. data/lib/better_auth/social_providers/twitter.rb +36 -1
  86. data/lib/better_auth/social_providers/vk.rb +34 -1
  87. data/lib/better_auth/social_providers/wechat.rb +6 -4
  88. data/lib/better_auth/sql_migration.rb +5 -5
  89. data/lib/better_auth/version.rb +1 -1
  90. data/lib/better_auth.rb +11 -41
  91. metadata +5 -13
  92. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  93. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  94. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  95. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  96. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  97. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  98. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  99. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  100. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  101. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  102. data/lib/better_auth/plugins/mcp.rb +0 -256
  103. data/lib/better_auth/plugins/oidc_provider.rb +0 -769
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BetterAuth
6
+ # Shared OAuth state storage matching upstream's opaque-state adaptation.
7
+ # State data is encrypted client-side for the cookie strategy or consumed
8
+ # from verification storage for the database strategy. Signed JWT state is
9
+ # still accepted so in-flight legacy sign-in and link flows keep working.
10
+ module OAuthState
11
+ INTERNAL_KEYS = %w[
12
+ callbackURL codeVerifier errorURL newUserURL expiresAt oauthState link requestSignUp
13
+ ].freeze
14
+
15
+ class Error < BetterAuth::Error
16
+ attr_reader :code, :error_url
17
+
18
+ def initialize(code, error_url: nil)
19
+ @code = code
20
+ @error_url = error_url
21
+ super(code)
22
+ end
23
+ end
24
+
25
+ module_function
26
+
27
+ def generate(ctx, state_data)
28
+ state = Crypto.random_string(32)
29
+ payload = stringify_keys(state_data).merge("oauthState" => state)
30
+
31
+ if cookie_strategy?(ctx)
32
+ cookie = ctx.context.create_auth_cookie("oauth_state", max_age: 600)
33
+ encrypted = Crypto.symmetric_encrypt(
34
+ key: ctx.context.secret_config,
35
+ data: JSON.generate(payload)
36
+ )
37
+ ctx.set_cookie(cookie.name, encrypted, cookie.attributes)
38
+ else
39
+ cookie = ctx.context.create_auth_cookie("state", max_age: 300)
40
+ ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
41
+ verification = ctx.context.internal_adapter.create_verification_value(
42
+ identifier: state,
43
+ value: JSON.generate(payload),
44
+ expiresAt: Time.now + 600
45
+ )
46
+ raise Error, "state_generation_error" unless verification
47
+ end
48
+
49
+ state
50
+ end
51
+
52
+ def parse(ctx, state)
53
+ raise Error, "state_not_found" if state.to_s.empty?
54
+
55
+ legacy = Crypto.verify_jwt(state.to_s, ctx.context.secret)
56
+ return parse_legacy(ctx, state, legacy) if legacy
57
+
58
+ cookie_strategy?(ctx) ? parse_cookie_state(ctx, state) : parse_database_state(ctx, state)
59
+ rescue JSON::ParserError
60
+ raise Error, "state_invalid"
61
+ end
62
+
63
+ def parse_legacy(ctx, state, data)
64
+ cookie = ctx.context.create_auth_cookie("state", max_age: 600)
65
+ stored = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
66
+ Cookies.expire_cookie(ctx, cookie) if ctx.request || stored
67
+ valid = ctx.request ? stored == state : (stored.nil? || stored == state)
68
+ return data if valid
69
+
70
+ raise Error.new("state_mismatch", error_url: recovered_error_url(data))
71
+ end
72
+
73
+ def parse_cookie_state(ctx, state)
74
+ cookie = ctx.context.create_auth_cookie("oauth_state")
75
+ encrypted = ctx.get_cookie(cookie.name)
76
+ raise Error, "state_mismatch" if encrypted.to_s.empty?
77
+
78
+ data = begin
79
+ decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: encrypted)
80
+ JSON.parse(decrypted)
81
+ rescue JSON::ParserError, ArgumentError, TypeError
82
+ raise Error, "state_invalid"
83
+ end
84
+
85
+ expected = data["oauthState"] || data["state"]
86
+ unless expected == state
87
+ raise Error.new("state_mismatch", error_url: recovered_error_url(data))
88
+ end
89
+
90
+ Cookies.expire_cookie(ctx, cookie)
91
+ validate_expiration!(data)
92
+ data
93
+ end
94
+
95
+ def parse_database_state(ctx, state)
96
+ preview = ctx.context.internal_adapter.find_verification_value(state)
97
+ raise Error, "state_mismatch" unless preview
98
+
99
+ data = JSON.parse(preview.fetch("value"))
100
+ error_url = recovered_error_url(data)
101
+ expected = data["oauthState"] || data["state"]
102
+ raise Error.new("state_mismatch", error_url: error_url) if expected && expected != state
103
+
104
+ cookie = ctx.context.create_auth_cookie("state")
105
+ stored = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
106
+ valid = ctx.request ? stored == state : (stored.nil? || stored == state)
107
+ raise Error.new("state_mismatch", error_url: error_url) unless valid
108
+
109
+ consumed = ctx.context.internal_adapter.consume_verification_value(state)
110
+ Cookies.expire_cookie(ctx, cookie) if ctx.request || stored
111
+ raise Error.new("state_mismatch", error_url: error_url) unless consumed
112
+
113
+ consumed_data = JSON.parse(consumed.fetch("value"))
114
+ consumed_expected = consumed_data["oauthState"] || consumed_data["state"]
115
+ same_payload = consumed.fetch("value") == preview.fetch("value")
116
+ expected_matches = !consumed_expected || consumed_expected == state
117
+ unless same_payload && expected_matches
118
+ raise Error.new("state_mismatch", error_url: recovered_error_url(consumed_data) || error_url)
119
+ end
120
+
121
+ validate_expiration!(consumed_data)
122
+ consumed_data
123
+ end
124
+
125
+ def validate_expiration!(data)
126
+ expires_at = data["expiresAt"].to_i
127
+ return data unless expires_at.positive? && expires_at < Time.now.to_i
128
+
129
+ raise Error.new("state_mismatch", error_url: recovered_error_url(data))
130
+ end
131
+
132
+ def recovered_error_url(data)
133
+ data["errorURL"] || data["errorCallbackURL"]
134
+ end
135
+
136
+ def cookie_strategy?(ctx)
137
+ ctx.context.options.account[:store_state_strategy].to_s == "cookie"
138
+ end
139
+
140
+ def stringify_keys(value)
141
+ return value.each_with_object({}) { |(key, object), result| result[key.to_s] = stringify_keys(object) } if value.is_a?(Hash)
142
+ return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)
143
+
144
+ value
145
+ end
146
+ end
147
+ end
@@ -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
 
@@ -158,6 +160,8 @@ module BetterAuth
158
160
  user_id = body[:user_id].to_s
159
161
  raise APIError.new("BAD_REQUEST", message: "userId is required") if user_id.empty?
160
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
+
161
165
  user = ctx.context.internal_adapter.update_user(user_id, update)
162
166
  ctx.json({user: Schema.parse_output(ctx.context.options, "user", user || {})})
163
167
  end
@@ -189,7 +193,7 @@ module BetterAuth
189
193
  elsif query[:email]
190
194
  ctx.context.internal_adapter.find_user_by_email(query[:email])&.fetch(:user)
191
195
  end
192
- 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
193
197
  ctx.json(Schema.parse_output(ctx.context.options, "user", user))
194
198
  end
195
199
  end
@@ -212,10 +216,10 @@ module BetterAuth
212
216
  required: ["email", "name"]
213
217
  )
214
218
  ) do |ctx|
215
- session = Routes.current_session(ctx, allow_nil: true)
219
+ session = Routes.current_session(ctx, allow_nil: true, sensitive: true)
216
220
  if session
217
221
  unless admin_permission?(session[:user], session[:user]["role"], {user: ["create"]}, config)
218
- 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"))
219
223
  end
220
224
  elsif !ctx.headers.empty?
221
225
  raise APIError.new("UNAUTHORIZED")
@@ -223,18 +227,25 @@ module BetterAuth
223
227
 
224
228
  body = normalize_hash(ctx.body)
225
229
  email = body[:email].to_s.downcase
226
- 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)
227
231
 
228
232
  if ctx.context.internal_adapter.find_user_by_email(email)
229
- 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"))
230
234
  end
231
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
232
243
  user = ctx.context.internal_adapter.create_user(data.merge(
233
244
  name: body[:name].to_s,
234
245
  email: email,
235
- role: admin_validate_roles!(body[:role] || config[:default_role], config)
246
+ role: requested_role.nil? ? config[:default_role] : admin_validate_roles!(requested_role, config)
236
247
  ).merge(body.key?(:image) ? {image: body[:image]} : {}), context: ctx)
237
- 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
238
249
 
239
250
  if body[:password].to_s != ""
240
251
  ctx.context.internal_adapter.link_account(userId: user["id"], providerId: "credential", accountId: user["id"], password: Routes.hash_password(ctx, body[:password]))
@@ -269,12 +280,38 @@ module BetterAuth
269
280
  admin_require_permission!(ctx, config, {user: ["update"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS"))
270
281
  body = normalize_hash(ctx.body)
271
282
  data = normalize_hash(body[:data] || body).except(:user_id, :data)
272
- 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
273
287
  if data.key?(:role)
274
288
  admin_require_permission!(ctx, config, {user: ["set-role"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE"))
275
289
  data[:role] = admin_validate_roles!(data[:role], config)
276
290
  end
277
- 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
278
315
  ctx.json(Schema.parse_output(ctx.context.options, "user", user))
279
316
  end
280
317
  end
@@ -357,11 +394,11 @@ module BetterAuth
357
394
  session = admin_require_permission!(ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"))
358
395
  body = normalize_hash(ctx.body)
359
396
  found = ctx.context.internal_adapter.find_user_by_id(body[:user_id])
360
- raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES.fetch("USER_NOT_FOUND")) unless found
361
- 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"]
362
399
  expires_in = body[:ban_expires_in] || config[:default_ban_expires_in]
363
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)
364
- ctx.context.internal_adapter.delete_sessions(body[:user_id])
401
+ ctx.context.internal_adapter.delete_user_sessions(body[:user_id])
365
402
  ctx.json({user: Schema.parse_output(ctx.context.options, "user", user)})
366
403
  end
367
404
  end
@@ -381,7 +418,10 @@ module BetterAuth
381
418
  )
382
419
  ) do |ctx|
383
420
  admin_require_permission!(ctx, config, {user: ["ban"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_BAN_USERS"))
384
- 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)
385
425
  ctx.json({user: Schema.parse_output(ctx.context.options, "user", user)})
386
426
  end
387
427
  end
@@ -411,11 +451,11 @@ module BetterAuth
411
451
  session = admin_require_permission!(ctx, config, {user: ["impersonate"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS"))
412
452
  body = normalize_hash(ctx.body)
413
453
  target = ctx.context.internal_adapter.find_user_by_id(body[:user_id])
414
- 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
415
455
  can_impersonate_admins = config[:allow_impersonating_admins] ||
416
456
  admin_permission?(session[:user], session[:user]["role"], {user: ["impersonate-admins"]}, config)
417
457
  if !can_impersonate_admins && admin_user?(target, config)
418
- 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"))
419
459
  end
420
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)
421
461
  raise APIError.new("INTERNAL_SERVER_ERROR", message: ADMIN_ERROR_CODES.fetch("FAILED_TO_CREATE_USER")) unless impersonated
@@ -524,7 +564,7 @@ module BetterAuth
524
564
  }
525
565
  ) do |ctx|
526
566
  admin_require_permission!(ctx, config, {session: ["revoke"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS"))
527
- 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])
528
568
  ctx.json({success: true})
529
569
  end
530
570
  end
@@ -553,8 +593,8 @@ module BetterAuth
553
593
  ) do |ctx|
554
594
  session = admin_require_permission!(ctx, config, {user: ["delete"]}, ADMIN_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS"))
555
595
  user_id = normalize_hash(ctx.body)[:user_id]
556
- raise APIError.new("BAD_REQUEST", message: ADMIN_ERROR_CODES.fetch("YOU_CANNOT_REMOVE_YOURSELF")) if user_id == session[:user]["id"]
557
- 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)
558
598
  ctx.context.internal_adapter.delete_user(user_id)
559
599
  ctx.json({success: true})
560
600
  end
@@ -590,9 +630,17 @@ module BetterAuth
590
630
  raise APIError.new("BAD_REQUEST", message: "userId is required") if user_id.empty?
591
631
  min = ctx.context.options.email_and_password[:min_password_length]
592
632
  max = ctx.context.options.email_and_password[:max_password_length]
593
- raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_SHORT")) if password.length < min
594
- raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("PASSWORD_TOO_LONG")) if password.length > max
595
- 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
596
644
  ctx.json({status: true})
597
645
  end
598
646
  end
@@ -631,7 +679,7 @@ module BetterAuth
631
679
  }
632
680
  }
633
681
  ) do |ctx|
634
- session = Routes.current_session(ctx, allow_nil: true)
682
+ session = Routes.current_session(ctx, allow_nil: true, sensitive: true)
635
683
  body = normalize_hash(ctx.body)
636
684
  permissions = body[:permissions] || body[:permission]
637
685
  unless permissions
@@ -723,7 +771,10 @@ module BetterAuth
723
771
  session = Routes.current_session(ctx, sensitive: true)
724
772
  return session if admin_permission?(session[:user], session[:user]["role"], permissions, config)
725
773
 
726
- 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)
727
778
  end
728
779
 
729
780
  def admin_permission?(user, role_string, permissions, config)
@@ -750,7 +801,7 @@ module BetterAuth
750
801
 
751
802
  def admin_validate_roles!(roles, config)
752
803
  unless Array(roles).all? { |role| role.is_a?(String) || role.is_a?(Symbol) }
753
- 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"))
754
805
  end
755
806
 
756
807
  parsed = admin_parse_roles(roles)
@@ -758,7 +809,7 @@ module BetterAuth
758
809
  defined_roles = (config[:roles] || {}).transform_keys(&:to_s)
759
810
  invalid = parsed.split(",", -1).reject { |role| admin_role_for(defined_roles, role) }
760
811
  if invalid.any?
761
- 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"))
762
813
  end
763
814
  end
764
815