better_auth 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +110 -18
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +589 -0
  6. data/lib/better_auth/adapters/memory.rb +235 -0
  7. data/lib/better_auth/adapters/mongodb.rb +9 -0
  8. data/lib/better_auth/adapters/mssql.rb +42 -0
  9. data/lib/better_auth/adapters/mysql.rb +33 -0
  10. data/lib/better_auth/adapters/postgres.rb +17 -0
  11. data/lib/better_auth/adapters/sql.rb +441 -0
  12. data/lib/better_auth/adapters/sqlite.rb +20 -0
  13. data/lib/better_auth/api.rb +226 -0
  14. data/lib/better_auth/api_error.rb +53 -0
  15. data/lib/better_auth/auth.rb +42 -0
  16. data/lib/better_auth/configuration.rb +399 -0
  17. data/lib/better_auth/context.rb +211 -0
  18. data/lib/better_auth/cookies.rb +278 -0
  19. data/lib/better_auth/core.rb +37 -1
  20. data/lib/better_auth/crypto/jwe.rb +76 -0
  21. data/lib/better_auth/crypto.rb +191 -0
  22. data/lib/better_auth/database_hooks.rb +114 -0
  23. data/lib/better_auth/endpoint.rb +326 -0
  24. data/lib/better_auth/error.rb +52 -0
  25. data/lib/better_auth/middleware/origin_check.rb +128 -0
  26. data/lib/better_auth/password.rb +120 -0
  27. data/lib/better_auth/plugin.rb +142 -0
  28. data/lib/better_auth/plugin_context.rb +16 -0
  29. data/lib/better_auth/plugin_registry.rb +67 -0
  30. data/lib/better_auth/plugins/access.rb +87 -0
  31. data/lib/better_auth/plugins/additional_fields.rb +29 -0
  32. data/lib/better_auth/plugins/admin/schema.rb +28 -0
  33. data/lib/better_auth/plugins/admin.rb +518 -0
  34. data/lib/better_auth/plugins/anonymous.rb +198 -0
  35. data/lib/better_auth/plugins/api_key.rb +16 -0
  36. data/lib/better_auth/plugins/bearer.rb +128 -0
  37. data/lib/better_auth/plugins/captcha.rb +159 -0
  38. data/lib/better_auth/plugins/custom_session.rb +84 -0
  39. data/lib/better_auth/plugins/device_authorization.rb +302 -0
  40. data/lib/better_auth/plugins/email_otp.rb +536 -0
  41. data/lib/better_auth/plugins/expo.rb +88 -0
  42. data/lib/better_auth/plugins/generic_oauth.rb +780 -0
  43. data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
  44. data/lib/better_auth/plugins/jwt.rb +482 -0
  45. data/lib/better_auth/plugins/last_login_method.rb +92 -0
  46. data/lib/better_auth/plugins/magic_link.rb +181 -0
  47. data/lib/better_auth/plugins/mcp.rb +342 -0
  48. data/lib/better_auth/plugins/multi_session.rb +173 -0
  49. data/lib/better_auth/plugins/oauth_protocol.rb +694 -0
  50. data/lib/better_auth/plugins/oauth_provider.rb +16 -0
  51. data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
  52. data/lib/better_auth/plugins/oidc_provider.rb +597 -0
  53. data/lib/better_auth/plugins/one_tap.rb +154 -0
  54. data/lib/better_auth/plugins/one_time_token.rb +106 -0
  55. data/lib/better_auth/plugins/open_api.rb +489 -0
  56. data/lib/better_auth/plugins/organization/schema.rb +106 -0
  57. data/lib/better_auth/plugins/organization.rb +995 -0
  58. data/lib/better_auth/plugins/passkey.rb +16 -0
  59. data/lib/better_auth/plugins/phone_number.rb +321 -0
  60. data/lib/better_auth/plugins/scim.rb +16 -0
  61. data/lib/better_auth/plugins/siwe.rb +242 -0
  62. data/lib/better_auth/plugins/sso.rb +16 -0
  63. data/lib/better_auth/plugins/stripe.rb +16 -0
  64. data/lib/better_auth/plugins/two_factor.rb +514 -0
  65. data/lib/better_auth/plugins/username.rb +278 -0
  66. data/lib/better_auth/plugins.rb +46 -0
  67. data/lib/better_auth/rate_limiter.rb +232 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +378 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +111 -0
  72. data/lib/better_auth/routes/error.rb +102 -0
  73. data/lib/better_auth/routes/ok.rb +15 -0
  74. data/lib/better_auth/routes/password.rb +183 -0
  75. data/lib/better_auth/routes/session.rb +160 -0
  76. data/lib/better_auth/routes/sign_in.rb +90 -0
  77. data/lib/better_auth/routes/sign_out.rb +15 -0
  78. data/lib/better_auth/routes/sign_up.rb +196 -0
  79. data/lib/better_auth/routes/social.rb +367 -0
  80. data/lib/better_auth/routes/user.rb +205 -0
  81. data/lib/better_auth/schema/sql.rb +202 -0
  82. data/lib/better_auth/schema.rb +291 -0
  83. data/lib/better_auth/session.rb +122 -0
  84. data/lib/better_auth/session_store.rb +91 -0
  85. data/lib/better_auth/social_providers/apple.rb +91 -0
  86. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  87. data/lib/better_auth/social_providers/base.rb +325 -0
  88. data/lib/better_auth/social_providers/cognito.rb +32 -0
  89. data/lib/better_auth/social_providers/discord.rb +81 -0
  90. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  91. data/lib/better_auth/social_providers/facebook.rb +35 -0
  92. data/lib/better_auth/social_providers/figma.rb +31 -0
  93. data/lib/better_auth/social_providers/github.rb +74 -0
  94. data/lib/better_auth/social_providers/gitlab.rb +67 -0
  95. data/lib/better_auth/social_providers/google.rb +90 -0
  96. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  97. data/lib/better_auth/social_providers/kakao.rb +32 -0
  98. data/lib/better_auth/social_providers/kick.rb +32 -0
  99. data/lib/better_auth/social_providers/line.rb +33 -0
  100. data/lib/better_auth/social_providers/linear.rb +44 -0
  101. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  102. data/lib/better_auth/social_providers/microsoft_entra_id.rb +137 -0
  103. data/lib/better_auth/social_providers/naver.rb +31 -0
  104. data/lib/better_auth/social_providers/notion.rb +33 -0
  105. data/lib/better_auth/social_providers/paybin.rb +31 -0
  106. data/lib/better_auth/social_providers/paypal.rb +36 -0
  107. data/lib/better_auth/social_providers/polar.rb +31 -0
  108. data/lib/better_auth/social_providers/railway.rb +49 -0
  109. data/lib/better_auth/social_providers/reddit.rb +32 -0
  110. data/lib/better_auth/social_providers/roblox.rb +31 -0
  111. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  112. data/lib/better_auth/social_providers/slack.rb +30 -0
  113. data/lib/better_auth/social_providers/spotify.rb +31 -0
  114. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  115. data/lib/better_auth/social_providers/twitch.rb +39 -0
  116. data/lib/better_auth/social_providers/twitter.rb +32 -0
  117. data/lib/better_auth/social_providers/vercel.rb +47 -0
  118. data/lib/better_auth/social_providers/vk.rb +34 -0
  119. data/lib/better_auth/social_providers/wechat.rb +104 -0
  120. data/lib/better_auth/social_providers/zoom.rb +31 -0
  121. data/lib/better_auth/social_providers.rb +38 -0
  122. data/lib/better_auth/version.rb +1 -1
  123. data/lib/better_auth.rb +86 -2
  124. metadata +233 -21
  125. data/.ruby-version +0 -1
  126. data/.standard.yml +0 -12
  127. data/.vscode/settings.json +0 -22
  128. data/AGENTS.md +0 -50
  129. data/CLAUDE.md +0 -1
  130. data/CODE_OF_CONDUCT.md +0 -173
  131. data/CONTRIBUTING.md +0 -187
  132. data/Gemfile +0 -12
  133. data/Makefile +0 -207
  134. data/Rakefile +0 -25
  135. data/SECURITY.md +0 -28
  136. data/docker-compose.yml +0 -63
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def passkey(*args)
8
+ Kernel.require "better_auth/passkey"
9
+ BetterAuth::Plugins.passkey(*args)
10
+ rescue LoadError => error
11
+ raise if error.path && error.path != "better_auth/passkey"
12
+
13
+ raise LoadError, "BetterAuth::Plugins.passkey requires the better_auth-passkey gem. Add `gem \"better_auth-passkey\"` and `require \"better_auth/passkey\"`."
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module BetterAuth
6
+ module Plugins
7
+ PHONE_NUMBER_ERROR_CODES = {
8
+ "INVALID_PHONE_NUMBER" => "Invalid phone number",
9
+ "PHONE_NUMBER_EXIST" => "Phone number already exists",
10
+ "PHONE_NUMBER_NOT_EXIST" => "phone number isn't registered",
11
+ "INVALID_PHONE_NUMBER_OR_PASSWORD" => "Invalid phone number or password",
12
+ "UNEXPECTED_ERROR" => "Unexpected error",
13
+ "OTP_NOT_FOUND" => "OTP not found",
14
+ "OTP_EXPIRED" => "OTP expired",
15
+ "INVALID_OTP" => "Invalid OTP",
16
+ "PHONE_NUMBER_NOT_VERIFIED" => "Phone number not verified",
17
+ "PHONE_NUMBER_CANNOT_BE_UPDATED" => "Phone number cannot be updated",
18
+ "SEND_OTP_NOT_IMPLEMENTED" => "sendOTP not implemented",
19
+ "TOO_MANY_ATTEMPTS" => "Too many attempts"
20
+ }.freeze
21
+
22
+ module_function
23
+
24
+ def phone_number(options = {})
25
+ config = {
26
+ expires_in: 300,
27
+ otp_length: 6,
28
+ allowed_attempts: 3,
29
+ phone_number: "phoneNumber",
30
+ phone_number_verified: "phoneNumberVerified"
31
+ }.merge(normalize_hash(options))
32
+
33
+ Plugin.new(
34
+ id: "phone-number",
35
+ hooks: {
36
+ before: [
37
+ {
38
+ matcher: ->(ctx) { ctx.path == "/sign-up/email" && normalize_hash(ctx.body).key?(:phone_number) },
39
+ handler: ->(ctx) { validate_unique_phone_number!(ctx, normalize_hash(ctx.body)[:phone_number]) }
40
+ },
41
+ {
42
+ matcher: ->(ctx) { ctx.path == "/update-user" && normalize_hash(ctx.body).key?(:phone_number) },
43
+ handler: ->(_ctx) { raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_CANNOT_BE_UPDATED"]) }
44
+ }
45
+ ]
46
+ },
47
+ endpoints: {
48
+ sign_in_phone_number: sign_in_phone_number_endpoint(config),
49
+ send_phone_number_otp: send_phone_number_otp_endpoint(config),
50
+ verify_phone_number: verify_phone_number_endpoint(config),
51
+ request_password_reset_phone_number: request_password_reset_phone_number_endpoint(config),
52
+ reset_password_phone_number: reset_password_phone_number_endpoint(config)
53
+ },
54
+ schema: phone_number_schema(config[:schema]),
55
+ rate_limit: [
56
+ {
57
+ path_matcher: ->(path) { path.start_with?("/phone-number") },
58
+ window: 60_000,
59
+ max: 10
60
+ }
61
+ ],
62
+ error_codes: PHONE_NUMBER_ERROR_CODES,
63
+ options: config
64
+ )
65
+ end
66
+
67
+ def sign_in_phone_number_endpoint(config)
68
+ Endpoint.new(path: "/sign-in/phone-number", method: "POST") do |ctx|
69
+ body = normalize_hash(ctx.body)
70
+ phone_number = body[:phone_number].to_s
71
+ password = body[:password].to_s
72
+ validate_phone_number!(config, phone_number)
73
+
74
+ found = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}])
75
+ unless found
76
+ Routes.hash_password(ctx, password)
77
+ raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["INVALID_PHONE_NUMBER_OR_PASSWORD"])
78
+ end
79
+
80
+ if config[:require_verification] && !found["phoneNumberVerified"]
81
+ code = phone_number_generate_code(config)
82
+ phone_number_store_code(ctx, config, phone_number, code)
83
+ phone_number_deliver_otp(config, {phone_number: phone_number, code: code}, ctx)
84
+ raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_NOT_VERIFIED"])
85
+ end
86
+
87
+ credential = ctx.context.internal_adapter.find_accounts(found["id"]).find { |entry| entry["providerId"] == "credential" }
88
+ current_password = credential && credential["password"]
89
+ unless current_password && Routes.verify_password_value(ctx, password, current_password)
90
+ Routes.hash_password(ctx, password) unless current_password
91
+ raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["INVALID_PHONE_NUMBER_OR_PASSWORD"])
92
+ end
93
+
94
+ dont_remember_me = body.key?(:remember_me) && (body[:remember_me] == false || body[:remember_me].to_s == "false")
95
+ session = ctx.context.internal_adapter.create_session(found["id"], dont_remember_me)
96
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session
97
+
98
+ Cookies.set_session_cookie(ctx, {session: session, user: found}, dont_remember_me)
99
+ ctx.json({token: session["token"], user: Schema.parse_output(ctx.context.options, "user", found)})
100
+ end
101
+ end
102
+
103
+ def send_phone_number_otp_endpoint(config)
104
+ Endpoint.new(path: "/phone-number/send-otp", method: "POST") do |ctx|
105
+ sender = config[:send_otp]
106
+ unless sender.respond_to?(:call)
107
+ raise APIError.new("NOT_IMPLEMENTED", message: PHONE_NUMBER_ERROR_CODES["SEND_OTP_NOT_IMPLEMENTED"])
108
+ end
109
+
110
+ body = normalize_hash(ctx.body)
111
+ phone_number = body[:phone_number].to_s
112
+ validate_phone_number!(config, phone_number)
113
+ code = phone_number_generate_code(config)
114
+ phone_number_store_code(ctx, config, phone_number, code)
115
+ phone_number_deliver_otp(config, {phone_number: phone_number, code: code}, ctx)
116
+ ctx.json({message: "code sent"})
117
+ end
118
+ end
119
+
120
+ def verify_phone_number_endpoint(config)
121
+ Endpoint.new(path: "/phone-number/verify", method: "POST") do |ctx|
122
+ body = normalize_hash(ctx.body)
123
+ phone_number = body[:phone_number].to_s
124
+ code = body[:code].to_s
125
+ phone_number_verify_code!(ctx, config, phone_number, code)
126
+
127
+ if truthy?(body[:update_phone_number])
128
+ session = Routes.current_session(ctx)
129
+ existing = ctx.context.adapter.find_many(model: "user", where: [{field: "phoneNumber", value: phone_number}])
130
+ unless existing.empty?
131
+ raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_EXIST"])
132
+ end
133
+
134
+ updated = ctx.context.internal_adapter.update_user(
135
+ session[:user]["id"],
136
+ phoneNumber: phone_number,
137
+ phoneNumberVerified: true
138
+ )
139
+ next ctx.json({status: true, token: session[:session]["token"], user: Schema.parse_output(ctx.context.options, "user", updated)})
140
+ end
141
+
142
+ user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}])
143
+ user = if user
144
+ ctx.context.internal_adapter.update_user(user["id"], phoneNumberVerified: true)
145
+ elsif config[:sign_up_on_verification]
146
+ phone_number_create_user(ctx, config, body, phone_number)
147
+ end
148
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_UPDATE_USER"]) unless user
149
+
150
+ callback = config[:callback_on_verification]
151
+ callback.call({phone_number: phone_number, user: user}, ctx) if callback.respond_to?(:call)
152
+
153
+ if truthy?(body[:disable_session])
154
+ next ctx.json({status: true, token: nil, user: Schema.parse_output(ctx.context.options, "user", user)})
155
+ end
156
+
157
+ session = ctx.context.internal_adapter.create_session(user["id"])
158
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session
159
+
160
+ Cookies.set_session_cookie(ctx, {session: session, user: user})
161
+ ctx.json({status: true, token: session["token"], user: Schema.parse_output(ctx.context.options, "user", user)})
162
+ end
163
+ end
164
+
165
+ def request_password_reset_phone_number_endpoint(config)
166
+ Endpoint.new(path: "/phone-number/request-password-reset", method: "POST") do |ctx|
167
+ body = normalize_hash(ctx.body)
168
+ phone_number = body[:phone_number].to_s
169
+ user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}])
170
+ code = phone_number_generate_code(config)
171
+ phone_number_store_code(ctx, config, "#{phone_number}-request-password-reset", code)
172
+
173
+ if user && config[:send_password_reset_otp].respond_to?(:call)
174
+ config[:send_password_reset_otp].call({phone_number: phone_number, code: code}, ctx)
175
+ end
176
+
177
+ ctx.json({status: true})
178
+ end
179
+ end
180
+
181
+ def reset_password_phone_number_endpoint(config)
182
+ Endpoint.new(path: "/phone-number/reset-password", method: "POST") do |ctx|
183
+ body = normalize_hash(ctx.body)
184
+ phone_number = body[:phone_number].to_s
185
+ otp = body[:otp].to_s
186
+ new_password = body[:new_password]
187
+
188
+ verification = phone_number_verify_code!(ctx, config, "#{phone_number}-request-password-reset", otp, consume: false)
189
+ user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}])
190
+ raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["UNEXPECTED_ERROR"]) unless user
191
+
192
+ Routes.validate_password_length!(new_password, ctx.context.options.email_and_password)
193
+ ctx.context.internal_adapter.update_password(user["id"], Routes.hash_password(ctx, new_password))
194
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
195
+ ctx.context.internal_adapter.delete_sessions(user["id"]) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
196
+ ctx.json({status: true})
197
+ end
198
+ end
199
+
200
+ def phone_number_schema(custom_schema)
201
+ base = {
202
+ user: {
203
+ fields: {
204
+ phoneNumber: {type: "string", required: false, unique: true, sortable: true, returned: true},
205
+ phoneNumberVerified: {type: "boolean", required: false, returned: true, input: false}
206
+ }
207
+ }
208
+ }
209
+ deep_merge_hashes(base, normalize_hash(custom_schema || {}))
210
+ end
211
+
212
+ def phone_number_create_user(ctx, config, body, phone_number)
213
+ sign_up = config[:sign_up_on_verification]
214
+ email_callback = sign_up[:get_temp_email]
215
+ name_callback = sign_up[:get_temp_name]
216
+ email = email_callback.respond_to?(:call) ? email_callback.call(phone_number) : "temp-#{phone_number}"
217
+ name = name_callback.respond_to?(:call) ? name_callback.call(phone_number) : phone_number
218
+ reserved = %i[phone_number code disable_session update_phone_number]
219
+ additional = body.reject { |key, _value| reserved.include?(key.to_sym) }
220
+
221
+ ctx.context.internal_adapter.create_user(
222
+ additional.merge(
223
+ "email" => email,
224
+ "name" => name,
225
+ "phoneNumber" => phone_number,
226
+ "phoneNumberVerified" => true,
227
+ "emailVerified" => false
228
+ )
229
+ )
230
+ end
231
+
232
+ def phone_number_verify_code!(ctx, config, identifier, code, consume: true)
233
+ verifier = config[:verify_otp]
234
+ if verifier.respond_to?(:call)
235
+ valid = verifier.call({phone_number: identifier.delete_suffix("-request-password-reset"), code: code}, ctx)
236
+ raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"]) unless valid
237
+
238
+ verification = ctx.context.internal_adapter.find_verification_value(identifier)
239
+ ctx.context.internal_adapter.delete_verification_value(verification["id"]) if consume && verification
240
+ return verification || true
241
+ end
242
+
243
+ verification = ctx.context.internal_adapter.find_verification_value(identifier)
244
+ raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_NOT_FOUND"]) unless verification
245
+
246
+ if Routes.expired_time?(verification["expiresAt"])
247
+ raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_EXPIRED"])
248
+ end
249
+
250
+ stored_code, attempts = phone_number_split_code(verification["value"])
251
+ attempts_count = attempts.to_i
252
+ if attempts_count >= config[:allowed_attempts].to_i
253
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
254
+ raise APIError.new("FORBIDDEN", message: PHONE_NUMBER_ERROR_CODES["TOO_MANY_ATTEMPTS"])
255
+ end
256
+
257
+ unless stored_code == code
258
+ ctx.context.internal_adapter.update_verification_value(verification["id"], value: "#{stored_code}:#{attempts_count + 1}")
259
+ raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"])
260
+ end
261
+
262
+ ctx.context.internal_adapter.delete_verification_value(verification["id"]) if consume
263
+ verification
264
+ end
265
+
266
+ def phone_number_store_code(ctx, config, identifier, code)
267
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
268
+ ctx.context.internal_adapter.create_verification_value(
269
+ identifier: identifier,
270
+ value: "#{code}:0",
271
+ expiresAt: Time.now + config[:expires_in].to_i
272
+ )
273
+ end
274
+
275
+ def phone_number_deliver_otp(config, data, ctx)
276
+ sender = config[:send_otp]
277
+ sender.call(data, ctx) if sender.respond_to?(:call)
278
+ end
279
+
280
+ def validate_unique_phone_number!(ctx, phone_number)
281
+ return if phone_number.to_s.empty?
282
+
283
+ existing = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number.to_s}])
284
+ raise APIError.new("UNPROCESSABLE_ENTITY", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_EXIST"]) if existing
285
+ end
286
+
287
+ def validate_phone_number!(config, phone_number)
288
+ validator = config[:phone_number_validator]
289
+ return unless validator.respond_to?(:call)
290
+ return if validator.call(phone_number)
291
+
292
+ raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_PHONE_NUMBER"])
293
+ end
294
+
295
+ def phone_number_generate_code(config)
296
+ Array.new(config[:otp_length].to_i) { SecureRandom.random_number(10).to_s }.join
297
+ end
298
+
299
+ def phone_number_split_code(value)
300
+ string = value.to_s
301
+ index = string.rindex(":")
302
+ return [string, ""] unless index
303
+
304
+ [string[0...index], string[(index + 1)..]]
305
+ end
306
+
307
+ def truthy?(value)
308
+ value == true || value.to_s == "true"
309
+ end
310
+
311
+ def deep_merge_hashes(base, override)
312
+ base.merge(override) do |_key, old_value, new_value|
313
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
314
+ deep_merge_hashes(old_value, new_value)
315
+ else
316
+ new_value
317
+ end
318
+ end
319
+ end
320
+ end
321
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def scim(*args)
8
+ Kernel.require "better_auth/scim"
9
+ BetterAuth::Plugins.scim(*args)
10
+ rescue LoadError => error
11
+ raise if error.path && error.path != "better_auth/scim"
12
+
13
+ raise LoadError, "BetterAuth::Plugins.scim requires the better_auth-scim gem. Add `gem \"better_auth-scim\"` and `require \"better_auth/scim\"`."
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module BetterAuth
6
+ module Plugins
7
+ module_function
8
+
9
+ SIWE_WALLET_PATTERN = /\A0[xX][a-fA-F0-9]{40}\z/
10
+ SIWE_EMAIL_PATTERN = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
11
+
12
+ def siwe(options = {})
13
+ config = normalize_hash(options)
14
+
15
+ Plugin.new(
16
+ id: "siwe",
17
+ schema: siwe_schema(config[:schema]),
18
+ endpoints: {
19
+ get_siwe_nonce: get_siwe_nonce_endpoint(config),
20
+ verify_siwe_message: verify_siwe_message_endpoint(config)
21
+ },
22
+ options: config
23
+ )
24
+ end
25
+
26
+ def get_siwe_nonce_endpoint(config)
27
+ Endpoint.new(path: "/siwe/nonce", method: "POST", body_schema: ->(body) { siwe_nonce_body(body) }) do |ctx|
28
+ body = normalize_hash(ctx.body)
29
+ wallet_address = siwe_normalize_wallet!(body[:wallet_address])
30
+ chain_id = siwe_chain_id(body[:chain_id])
31
+ nonce_callback = config[:get_nonce]
32
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "SIWE nonce callback is required") unless nonce_callback.respond_to?(:call)
33
+
34
+ nonce = nonce_callback.call.to_s
35
+ ctx.context.internal_adapter.create_verification_value(
36
+ identifier: siwe_identifier(wallet_address, chain_id),
37
+ value: nonce,
38
+ expiresAt: Time.now + (15 * 60)
39
+ )
40
+ ctx.json({nonce: nonce})
41
+ end
42
+ end
43
+
44
+ def verify_siwe_message_endpoint(config)
45
+ Endpoint.new(path: "/siwe/verify", method: "POST", body_schema: ->(body) { siwe_verify_body(body, config) }) do |ctx|
46
+ body = normalize_hash(ctx.body)
47
+ wallet_address = siwe_normalize_wallet!(body[:wallet_address])
48
+ chain_id = siwe_chain_id(body[:chain_id])
49
+ email = body[:email].to_s
50
+ anonymous = config.key?(:anonymous) ? config[:anonymous] : true
51
+ raise APIError.new("BAD_REQUEST", message: "Email is required when anonymous is disabled.") if anonymous == false && email.empty?
52
+ raise APIError.new("BAD_REQUEST", message: "Invalid email address") if !email.empty? && !SIWE_EMAIL_PATTERN.match?(email)
53
+
54
+ verification = ctx.context.internal_adapter.find_verification_value(siwe_identifier(wallet_address, chain_id))
55
+ if !verification || siwe_expired_time?(verification["expiresAt"])
56
+ raise APIError.new("UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE", message: "Unauthorized: Invalid or expired nonce")
57
+ end
58
+
59
+ verified = siwe_verify_message(config, body, wallet_address, chain_id, verification["value"], ctx)
60
+ raise APIError.new("UNAUTHORIZED", message: "Unauthorized: Invalid SIWE signature") unless verified
61
+
62
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
63
+
64
+ user = siwe_find_user(ctx, wallet_address, chain_id)
65
+ user ||= siwe_create_user(ctx, config, wallet_address, chain_id, email, anonymous)
66
+ siwe_ensure_wallet_and_account(ctx, user, wallet_address, chain_id)
67
+ session = ctx.context.internal_adapter.create_session(user["id"])
68
+ session_data = {session: session, user: user}
69
+ Cookies.set_session_cookie(ctx, session_data)
70
+
71
+ ctx.json({
72
+ token: session["token"],
73
+ success: true,
74
+ user: {
75
+ id: user["id"],
76
+ walletAddress: wallet_address,
77
+ chainId: chain_id
78
+ }
79
+ })
80
+ rescue APIError
81
+ raise
82
+ rescue
83
+ raise APIError.new("UNAUTHORIZED", message: "Something went wrong. Please try again later.")
84
+ end
85
+ end
86
+
87
+ def siwe_schema(custom_schema = nil)
88
+ base = {
89
+ "walletAddress" => {
90
+ fields: {
91
+ userId: {type: "string", references: {model: "user", field: "id"}, required: true, index: true},
92
+ address: {type: "string", required: true},
93
+ chainId: {type: "number", required: true},
94
+ isPrimary: {type: "boolean", default_value: false},
95
+ createdAt: {type: "date", required: true}
96
+ }
97
+ }
98
+ }
99
+ return base unless custom_schema.is_a?(Hash)
100
+
101
+ normalize_hash(custom_schema).each_with_object(base) do |(raw_model, table), result|
102
+ model = Schema.storage_key(raw_model)
103
+ current = result[model] || {}
104
+ custom_table = normalize_hash(table)
105
+ fields = siwe_merge_schema_fields(current[:fields] || current["fields"] || {}, custom_table.delete(:fields) || {})
106
+ result[model] = current.merge(custom_table).merge(fields: fields)
107
+ end
108
+ end
109
+
110
+ def siwe_merge_schema_fields(base_fields, custom_fields)
111
+ fields = base_fields.each_with_object({}) do |(raw_field, attributes), result|
112
+ result[Schema.storage_key(raw_field)] = normalize_hash(attributes)
113
+ end
114
+
115
+ normalize_hash(custom_fields).each do |raw_field, value|
116
+ field = Schema.storage_key(raw_field)
117
+ custom_attributes = (value.is_a?(String) || value.is_a?(Symbol)) ? {field_name: value.to_s} : normalize_hash(value)
118
+ fields[field] = (fields[field] || {}).merge(custom_attributes)
119
+ end
120
+
121
+ fields
122
+ end
123
+
124
+ def siwe_nonce_body(body)
125
+ data = normalize_hash(body)
126
+ siwe_normalize_wallet!(data[:wallet_address])
127
+ data[:chain_id] = siwe_chain_id(data[:chain_id])
128
+ data
129
+ end
130
+
131
+ def siwe_verify_body(body, config)
132
+ data = normalize_hash(body)
133
+ raise APIError.new("BAD_REQUEST", message: "message is required") if data[:message].to_s.empty?
134
+ raise APIError.new("BAD_REQUEST", message: "signature is required") if data[:signature].to_s.empty?
135
+
136
+ siwe_normalize_wallet!(data[:wallet_address])
137
+ data[:chain_id] = siwe_chain_id(data[:chain_id])
138
+ anonymous = config.key?(:anonymous) ? config[:anonymous] : true
139
+ email = data[:email].to_s
140
+ raise APIError.new("BAD_REQUEST", message: "Email is required when anonymous is disabled.") if anonymous == false && email.empty?
141
+ raise APIError.new("BAD_REQUEST", message: "Invalid email address") if !email.empty? && !SIWE_EMAIL_PATTERN.match?(email)
142
+
143
+ data
144
+ end
145
+
146
+ def siwe_normalize_wallet!(value)
147
+ wallet = value.to_s
148
+ raise APIError.new("BAD_REQUEST", message: "Invalid walletAddress") unless SIWE_WALLET_PATTERN.match?(wallet)
149
+
150
+ Crypto.to_checksum_address(wallet)
151
+ end
152
+
153
+ def siwe_chain_id(value)
154
+ chain_id = (value.nil? || value.to_s.empty?) ? 1 : value.to_i
155
+ raise APIError.new("BAD_REQUEST", message: "Invalid chainId") unless chain_id.positive? && chain_id <= 2_147_483_647
156
+
157
+ chain_id
158
+ end
159
+
160
+ def siwe_identifier(wallet_address, chain_id)
161
+ "siwe:#{wallet_address}:#{chain_id}"
162
+ end
163
+
164
+ def siwe_verify_message(config, body, wallet_address, chain_id, nonce, ctx)
165
+ verifier = config[:verify_message]
166
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "SIWE verify_message callback is required") unless verifier.respond_to?(:call)
167
+
168
+ verifier.call(
169
+ message: body[:message].to_s,
170
+ signature: body[:signature].to_s,
171
+ address: wallet_address,
172
+ chain_id: chain_id,
173
+ cacao: {
174
+ h: {t: "caip122"},
175
+ p: {
176
+ domain: config[:domain],
177
+ aud: config[:domain],
178
+ nonce: nonce,
179
+ iss: config[:domain],
180
+ version: "1"
181
+ },
182
+ s: {t: "eip191", s: body[:signature].to_s}
183
+ }
184
+ )
185
+ end
186
+
187
+ def siwe_find_user(ctx, wallet_address, chain_id)
188
+ existing = ctx.context.adapter.find_one(
189
+ model: "walletAddress",
190
+ where: [
191
+ {field: "address", value: wallet_address},
192
+ {field: "chainId", value: chain_id}
193
+ ]
194
+ )
195
+ existing ||= ctx.context.adapter.find_one(model: "walletAddress", where: [{field: "address", value: wallet_address}])
196
+ existing && ctx.context.internal_adapter.find_user_by_id(existing["userId"])
197
+ end
198
+
199
+ def siwe_create_user(ctx, config, wallet_address, _chain_id, email, anonymous)
200
+ domain = config[:email_domain_name] || URI.parse(ctx.context.base_url).host || ctx.context.base_url
201
+ lookup = config[:ens_lookup]
202
+ ens = lookup.respond_to?(:call) ? normalize_hash(lookup.call(wallet_address: wallet_address) || {}) : {}
203
+ ctx.context.internal_adapter.create_user(
204
+ name: ens[:name] || wallet_address,
205
+ email: (anonymous == false && !email.empty?) ? email : "#{wallet_address}@#{domain}",
206
+ image: ens[:avatar] || ""
207
+ )
208
+ end
209
+
210
+ def siwe_ensure_wallet_and_account(ctx, user, wallet_address, chain_id)
211
+ exact = ctx.context.adapter.find_one(
212
+ model: "walletAddress",
213
+ where: [
214
+ {field: "address", value: wallet_address},
215
+ {field: "chainId", value: chain_id}
216
+ ]
217
+ )
218
+ return if exact
219
+
220
+ any_wallet = ctx.context.adapter.find_one(model: "walletAddress", where: [{field: "address", value: wallet_address}])
221
+ ctx.context.adapter.create(
222
+ model: "walletAddress",
223
+ data: {
224
+ userId: user["id"],
225
+ address: wallet_address,
226
+ chainId: chain_id,
227
+ isPrimary: any_wallet.nil?,
228
+ createdAt: Time.now
229
+ }
230
+ )
231
+ ctx.context.internal_adapter.create_account(
232
+ userId: user["id"],
233
+ providerId: "siwe",
234
+ accountId: "#{wallet_address}:#{chain_id}"
235
+ )
236
+ end
237
+
238
+ def siwe_expired_time?(value)
239
+ value && value < Time.now
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def sso(*args)
8
+ Kernel.require "better_auth/sso"
9
+ BetterAuth::Plugins.sso(*args)
10
+ rescue LoadError => error
11
+ raise if error.path && error.path != "better_auth/sso"
12
+
13
+ raise LoadError, "BetterAuth::Plugins.sso requires the better_auth-sso gem. Add `gem \"better_auth-sso\"` and `require \"better_auth/sso\"`."
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def stripe(*args)
8
+ Kernel.require "better_auth/stripe"
9
+ BetterAuth::Plugins.stripe(*args)
10
+ rescue LoadError => error
11
+ raise if error.path && error.path != "better_auth/stripe"
12
+
13
+ raise LoadError, "BetterAuth::Plugins.stripe requires the better_auth-stripe gem. Add `gem \"better_auth-stripe\"` and `require \"better_auth/stripe\"`."
14
+ end
15
+ end
16
+ end