better_auth 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +106 -16
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +439 -0
  6. data/lib/better_auth/adapters/memory.rb +232 -0
  7. data/lib/better_auth/adapters/mongodb.rb +369 -0
  8. data/lib/better_auth/adapters/mssql.rb +42 -0
  9. data/lib/better_auth/adapters/mysql.rb +33 -0
  10. data/lib/better_auth/adapters/postgres.rb +17 -0
  11. data/lib/better_auth/adapters/sql.rb +425 -0
  12. data/lib/better_auth/adapters/sqlite.rb +20 -0
  13. data/lib/better_auth/api.rb +226 -0
  14. data/lib/better_auth/api_error.rb +53 -0
  15. data/lib/better_auth/auth.rb +42 -0
  16. data/lib/better_auth/configuration.rb +399 -0
  17. data/lib/better_auth/context.rb +210 -0
  18. data/lib/better_auth/cookies.rb +278 -0
  19. data/lib/better_auth/core.rb +37 -1
  20. data/lib/better_auth/crypto/jwe.rb +76 -0
  21. data/lib/better_auth/crypto.rb +191 -0
  22. data/lib/better_auth/database_hooks.rb +114 -0
  23. data/lib/better_auth/endpoint.rb +326 -0
  24. data/lib/better_auth/error.rb +52 -0
  25. data/lib/better_auth/middleware/origin_check.rb +128 -0
  26. data/lib/better_auth/password.rb +120 -0
  27. data/lib/better_auth/plugin.rb +129 -0
  28. data/lib/better_auth/plugin_context.rb +16 -0
  29. data/lib/better_auth/plugin_registry.rb +67 -0
  30. data/lib/better_auth/plugins/access.rb +87 -0
  31. data/lib/better_auth/plugins/additional_fields.rb +29 -0
  32. data/lib/better_auth/plugins/admin/schema.rb +28 -0
  33. data/lib/better_auth/plugins/admin.rb +518 -0
  34. data/lib/better_auth/plugins/anonymous.rb +198 -0
  35. data/lib/better_auth/plugins/api_key.rb +16 -0
  36. data/lib/better_auth/plugins/bearer.rb +128 -0
  37. data/lib/better_auth/plugins/captcha.rb +159 -0
  38. data/lib/better_auth/plugins/custom_session.rb +84 -0
  39. data/lib/better_auth/plugins/device_authorization.rb +302 -0
  40. data/lib/better_auth/plugins/email_otp.rb +536 -0
  41. data/lib/better_auth/plugins/expo.rb +88 -0
  42. data/lib/better_auth/plugins/generic_oauth.rb +780 -0
  43. data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
  44. data/lib/better_auth/plugins/jwt.rb +482 -0
  45. data/lib/better_auth/plugins/last_login_method.rb +92 -0
  46. data/lib/better_auth/plugins/magic_link.rb +181 -0
  47. data/lib/better_auth/plugins/mcp.rb +342 -0
  48. data/lib/better_auth/plugins/multi_session.rb +173 -0
  49. data/lib/better_auth/plugins/oauth_protocol.rb +348 -0
  50. data/lib/better_auth/plugins/oauth_provider.rb +16 -0
  51. data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
  52. data/lib/better_auth/plugins/oidc_provider.rb +597 -0
  53. data/lib/better_auth/plugins/one_tap.rb +154 -0
  54. data/lib/better_auth/plugins/one_time_token.rb +106 -0
  55. data/lib/better_auth/plugins/open_api.rb +489 -0
  56. data/lib/better_auth/plugins/organization/schema.rb +106 -0
  57. data/lib/better_auth/plugins/organization.rb +990 -0
  58. data/lib/better_auth/plugins/passkey.rb +16 -0
  59. data/lib/better_auth/plugins/phone_number.rb +321 -0
  60. data/lib/better_auth/plugins/scim.rb +16 -0
  61. data/lib/better_auth/plugins/siwe.rb +242 -0
  62. data/lib/better_auth/plugins/sso.rb +16 -0
  63. data/lib/better_auth/plugins/stripe.rb +16 -0
  64. data/lib/better_auth/plugins/two_factor.rb +514 -0
  65. data/lib/better_auth/plugins/username.rb +278 -0
  66. data/lib/better_auth/plugins.rb +46 -0
  67. data/lib/better_auth/rate_limiter.rb +215 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +365 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +108 -0
  72. data/lib/better_auth/routes/error.rb +102 -0
  73. data/lib/better_auth/routes/ok.rb +15 -0
  74. data/lib/better_auth/routes/password.rb +164 -0
  75. data/lib/better_auth/routes/session.rb +137 -0
  76. data/lib/better_auth/routes/sign_in.rb +90 -0
  77. data/lib/better_auth/routes/sign_out.rb +15 -0
  78. data/lib/better_auth/routes/sign_up.rb +145 -0
  79. data/lib/better_auth/routes/social.rb +188 -0
  80. data/lib/better_auth/routes/user.rb +193 -0
  81. data/lib/better_auth/schema/sql.rb +191 -0
  82. data/lib/better_auth/schema.rb +275 -0
  83. data/lib/better_auth/session.rb +122 -0
  84. data/lib/better_auth/session_store.rb +91 -0
  85. data/lib/better_auth/social_providers/apple.rb +55 -0
  86. data/lib/better_auth/social_providers/base.rb +67 -0
  87. data/lib/better_auth/social_providers/discord.rb +59 -0
  88. data/lib/better_auth/social_providers/github.rb +59 -0
  89. data/lib/better_auth/social_providers/gitlab.rb +54 -0
  90. data/lib/better_auth/social_providers/google.rb +65 -0
  91. data/lib/better_auth/social_providers/microsoft_entra_id.rb +65 -0
  92. data/lib/better_auth/social_providers.rb +9 -0
  93. data/lib/better_auth/version.rb +1 -1
  94. data/lib/better_auth.rb +87 -2
  95. metadata +218 -21
  96. data/.ruby-version +0 -1
  97. data/.standard.yml +0 -12
  98. data/.vscode/settings.json +0 -22
  99. data/AGENTS.md +0 -50
  100. data/CLAUDE.md +0 -1
  101. data/CODE_OF_CONDUCT.md +0 -173
  102. data/CONTRIBUTING.md +0 -187
  103. data/Gemfile +0 -12
  104. data/Makefile +0 -207
  105. data/Rakefile +0 -25
  106. data/SECURITY.md +0 -28
  107. data/docker-compose.yml +0 -63
@@ -0,0 +1,536 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ EMAIL_OTP_ERROR_CODES = {
6
+ "OTP_EXPIRED" => "OTP expired",
7
+ "INVALID_OTP" => "Invalid OTP",
8
+ "TOO_MANY_ATTEMPTS" => "Too many attempts"
9
+ }.freeze
10
+
11
+ module_function
12
+
13
+ def email_otp(options = {})
14
+ config = {
15
+ expires_in: 5 * 60,
16
+ otp_length: 6,
17
+ store_otp: "plain",
18
+ allowed_attempts: 3
19
+ }.merge(normalize_hash(options))
20
+
21
+ Plugin.new(
22
+ id: "email-otp",
23
+ init: email_otp_init(config),
24
+ endpoints: {
25
+ send_verification_otp: send_verification_otp_endpoint(config),
26
+ create_verification_otp: create_verification_otp_endpoint(config),
27
+ get_verification_otp: get_verification_otp_endpoint(config),
28
+ check_verification_otp: check_verification_otp_endpoint(config),
29
+ verify_email_otp: verify_email_otp_endpoint(config),
30
+ sign_in_email_otp: sign_in_email_otp_endpoint(config),
31
+ request_password_reset_email_otp: request_password_reset_email_otp_endpoint(config),
32
+ forget_password_email_otp: forget_password_email_otp_endpoint(config),
33
+ reset_password_email_otp: reset_password_email_otp_endpoint(config),
34
+ request_email_change_email_otp: request_email_change_email_otp_endpoint(config),
35
+ change_email_email_otp: change_email_email_otp_endpoint(config)
36
+ },
37
+ hooks: {
38
+ after: [
39
+ {
40
+ matcher: ->(ctx) { ctx.path.to_s.start_with?("/sign-up") && config[:send_verification_on_sign_up] && !config[:override_default_email_verification] },
41
+ handler: ->(ctx) { email_otp_after_sign_up(ctx, config) }
42
+ }
43
+ ]
44
+ },
45
+ rate_limit: email_otp_rate_limits(config),
46
+ error_codes: EMAIL_OTP_ERROR_CODES,
47
+ options: config
48
+ )
49
+ end
50
+
51
+ def email_otp_init(config)
52
+ lambda do |context|
53
+ next unless config[:override_default_email_verification]
54
+
55
+ {
56
+ options: {
57
+ email_verification: {
58
+ send_verification_email: lambda do |data, request = nil|
59
+ user = fetch_value(data, :user) || data
60
+ email = fetch_value(user, :email).to_s
61
+ endpoint_context = Endpoint::Context.new(
62
+ path: "/send-verification-email",
63
+ method: "POST",
64
+ query: {},
65
+ body: {"email" => email, "type" => "email-verification"},
66
+ params: {},
67
+ headers: {},
68
+ context: context,
69
+ request: request
70
+ )
71
+ email_otp_send_verification(endpoint_context, config, email: email, type: "email-verification")
72
+ end
73
+ }
74
+ }
75
+ }
76
+ end
77
+ end
78
+
79
+ def send_verification_otp_endpoint(config)
80
+ Endpoint.new(path: "/email-otp/send-verification-otp", method: "POST") do |ctx|
81
+ body = normalize_hash(ctx.body)
82
+ email = body[:email].to_s.downcase
83
+ type = body[:type].to_s
84
+ validate_email_otp_type!(type)
85
+ validate_email_otp_email!(email)
86
+ if type == "change-email"
87
+ raise APIError.new("BAD_REQUEST", message: "Invalid OTP type")
88
+ end
89
+
90
+ sender = config[:send_verification_otp]
91
+ unless sender.respond_to?(:call)
92
+ raise APIError.new("BAD_REQUEST", message: "send email verification is not implemented")
93
+ end
94
+
95
+ email_otp_send_verification(ctx, config, email: email, type: type)
96
+ ctx.json({success: true})
97
+ end
98
+ end
99
+
100
+ def create_verification_otp_endpoint(config)
101
+ Endpoint.new(method: "POST") do |ctx|
102
+ body = normalize_hash(ctx.body)
103
+ email = body[:email].to_s.downcase
104
+ type = body[:type].to_s
105
+ validate_email_otp_type!(type)
106
+
107
+ otp = email_otp_generate(config, email: email, type: type, ctx: ctx)
108
+ email_otp_store(ctx, config, email: email, type: type, otp: otp)
109
+ otp
110
+ end
111
+ end
112
+
113
+ def get_verification_otp_endpoint(config)
114
+ Endpoint.new(path: "/email-otp/get-verification-otp", method: "GET") do |ctx|
115
+ query = normalize_hash(ctx.query)
116
+ email = query[:email].to_s.downcase
117
+ type = query[:type].to_s
118
+ validate_email_otp_type!(type)
119
+ verification = ctx.context.internal_adapter.find_verification_value(email_otp_identifier(email, type))
120
+ next ctx.json({otp: nil}) unless verification && !Routes.expired_time?(verification["expiresAt"])
121
+
122
+ stored_otp, = email_otp_split(verification["value"])
123
+ case config[:store_otp].to_s
124
+ when "hashed"
125
+ raise APIError.new("BAD_REQUEST", message: "OTP is hashed, cannot return the plain text OTP")
126
+ when "encrypted"
127
+ next ctx.json({otp: Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored_otp)})
128
+ end
129
+
130
+ storage = config[:store_otp]
131
+ if storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
132
+ raise APIError.new("BAD_REQUEST", message: "OTP is hashed, cannot return the plain text OTP")
133
+ elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
134
+ next ctx.json({otp: storage[:decrypt].call(stored_otp)})
135
+ end
136
+
137
+ ctx.json({otp: stored_otp})
138
+ end
139
+ end
140
+
141
+ def check_verification_otp_endpoint(config)
142
+ Endpoint.new(path: "/email-otp/check-verification-otp", method: "POST") do |ctx|
143
+ body = normalize_hash(ctx.body)
144
+ email = body[:email].to_s.downcase
145
+ type = body[:type].to_s
146
+ otp = body[:otp].to_s
147
+ validate_email_otp_type!(type)
148
+ validate_email_otp_email!(email)
149
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless ctx.context.internal_adapter.find_user_by_email(email)
150
+
151
+ email_otp_verify!(ctx, config, email: email, type: type, otp: otp, consume: false)
152
+ ctx.json({success: true})
153
+ end
154
+ end
155
+
156
+ def verify_email_otp_endpoint(config)
157
+ Endpoint.new(path: "/email-otp/verify-email", method: "POST") do |ctx|
158
+ body = normalize_hash(ctx.body)
159
+ email = body[:email].to_s.downcase
160
+ otp = body[:otp].to_s
161
+ validate_email_otp_email!(email)
162
+
163
+ email_otp_verify!(ctx, config, email: email, type: "email-verification", otp: otp)
164
+ found = ctx.context.internal_adapter.find_user_by_email(email)
165
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless found
166
+
167
+ user = found[:user]
168
+ call_email_verification_option(ctx, :before_email_verification, user)
169
+ updated = ctx.context.internal_adapter.update_user(user["id"], email: email, emailVerified: true)
170
+ call_email_verification_option(ctx, :on_email_verification, updated)
171
+ call_email_verification_option(ctx, :after_email_verification, updated)
172
+
173
+ if ctx.context.options.email_verification[:auto_sign_in_after_verification]
174
+ session = ctx.context.internal_adapter.create_session(updated["id"])
175
+ Cookies.set_session_cookie(ctx, {session: session, user: updated})
176
+ next ctx.json({status: true, token: session["token"], user: Schema.parse_output(ctx.context.options, "user", updated)})
177
+ end
178
+
179
+ current = Routes.current_session(ctx, allow_nil: true)
180
+ Cookies.set_session_cookie(ctx, {session: current[:session], user: updated}) if current
181
+ ctx.json({status: true, token: nil, user: Schema.parse_output(ctx.context.options, "user", updated)})
182
+ end
183
+ end
184
+
185
+ def sign_in_email_otp_endpoint(config)
186
+ Endpoint.new(path: "/sign-in/email-otp", method: "POST") do |ctx|
187
+ body = normalize_hash(ctx.body)
188
+ email = body[:email].to_s.downcase
189
+ otp = body[:otp].to_s
190
+
191
+ email_otp_verify!(ctx, config, email: email, type: "sign-in", otp: otp)
192
+ found = ctx.context.internal_adapter.find_user_by_email(email)
193
+ user = if found
194
+ found[:user]
195
+ else
196
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) if config[:disable_sign_up]
197
+
198
+ ctx.context.internal_adapter.create_user(email_otp_sign_up_user_data(body, email))
199
+ end
200
+
201
+ unless user["emailVerified"]
202
+ user = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true)
203
+ end
204
+
205
+ session = ctx.context.internal_adapter.create_session(user["id"])
206
+ Cookies.set_session_cookie(ctx, {session: session, user: user})
207
+ ctx.json({token: session["token"], user: Schema.parse_output(ctx.context.options, "user", user)})
208
+ end
209
+ end
210
+
211
+ def request_email_change_email_otp_endpoint(config)
212
+ Endpoint.new(path: "/email-otp/request-email-change", method: "POST") do |ctx|
213
+ email_otp_change_email_enabled!(config)
214
+ session = Routes.current_session(ctx)
215
+ body = normalize_hash(ctx.body)
216
+ current_email = session[:user]["email"].to_s.downcase
217
+ new_email = body[:new_email].to_s.downcase
218
+ validate_email_otp_email!(new_email)
219
+ raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == current_email
220
+
221
+ if config.dig(:change_email, :verify_current_email)
222
+ raise APIError.new("BAD_REQUEST", message: "OTP is required to verify current email") if body[:otp].to_s.empty?
223
+ email_otp_verify!(ctx, config, email: current_email, type: "email-verification", otp: body[:otp])
224
+ end
225
+
226
+ otp = email_otp_resolve(ctx, config, email: new_email, type: "change-email", identifier_email: "#{current_email}-#{new_email}")
227
+ if ctx.context.internal_adapter.find_user_by_email(new_email)
228
+ ctx.context.internal_adapter.delete_verification_by_identifier(email_otp_identifier("#{current_email}-#{new_email}", "change-email"))
229
+ next ctx.json({success: true})
230
+ end
231
+
232
+ email_otp_deliver(config, {email: new_email, otp: otp, type: "change-email"}, ctx)
233
+ ctx.json({success: true})
234
+ end
235
+ end
236
+
237
+ def change_email_email_otp_endpoint(config)
238
+ Endpoint.new(path: "/email-otp/change-email", method: "POST") do |ctx|
239
+ email_otp_change_email_enabled!(config)
240
+ session = Routes.current_session(ctx)
241
+ body = normalize_hash(ctx.body)
242
+ current_email = session[:user]["email"].to_s.downcase
243
+ new_email = body[:new_email].to_s.downcase
244
+ validate_email_otp_email!(new_email)
245
+ raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == current_email
246
+
247
+ email_otp_verify!(ctx, config, email: "#{current_email}-#{new_email}", type: "change-email", otp: body[:otp].to_s)
248
+ raise APIError.new("BAD_REQUEST", message: "Email already in use") if ctx.context.internal_adapter.find_user_by_email(new_email)
249
+
250
+ current = ctx.context.internal_adapter.find_user_by_email(current_email)
251
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless current
252
+
253
+ call_email_verification_option(ctx, :before_email_verification, current[:user])
254
+ updated = ctx.context.internal_adapter.update_user(current[:user]["id"], email: new_email, emailVerified: true)
255
+ call_email_verification_option(ctx, :after_email_verification, updated)
256
+ Cookies.set_session_cookie(ctx, {session: session[:session], user: updated})
257
+ ctx.json({success: true})
258
+ end
259
+ end
260
+
261
+ def request_password_reset_email_otp_endpoint(config)
262
+ Endpoint.new(path: "/email-otp/request-password-reset", method: "POST") do |ctx|
263
+ email_otp_password_reset_request(ctx, config)
264
+ end
265
+ end
266
+
267
+ def forget_password_email_otp_endpoint(config)
268
+ Endpoint.new(path: "/forget-password/email-otp", method: "POST") do |ctx|
269
+ email_otp_password_reset_request(ctx, config)
270
+ end
271
+ end
272
+
273
+ def reset_password_email_otp_endpoint(config)
274
+ Endpoint.new(path: "/email-otp/reset-password", method: "POST") do |ctx|
275
+ body = normalize_hash(ctx.body)
276
+ email = body[:email].to_s.downcase
277
+ otp = body[:otp].to_s
278
+ password = body[:password].to_s
279
+
280
+ email_otp_verify!(ctx, config, email: email, type: "forget-password", otp: otp)
281
+ found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true)
282
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["USER_NOT_FOUND"]) unless found
283
+
284
+ Routes.validate_password_length!(password, ctx.context.options.email_and_password)
285
+ hashed = Routes.hash_password(ctx, password)
286
+ account = found[:accounts].find { |entry| entry["providerId"] == "credential" }
287
+ if account
288
+ ctx.context.internal_adapter.update_password(found[:user]["id"], hashed)
289
+ else
290
+ ctx.context.internal_adapter.create_account(userId: found[:user]["id"], providerId: "credential", accountId: found[:user]["id"], password: hashed)
291
+ end
292
+
293
+ ctx.context.internal_adapter.update_user(found[:user]["id"], emailVerified: true) unless found[:user]["emailVerified"]
294
+ callback = ctx.context.options.email_and_password[:on_password_reset]
295
+ callback.call({user: found[:user]}, ctx.request) if callback.respond_to?(:call)
296
+ ctx.context.internal_adapter.delete_sessions(found[:user]["id"]) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
297
+ ctx.json({success: true})
298
+ end
299
+ end
300
+
301
+ def email_otp_after_sign_up(ctx, config)
302
+ response = ctx.returned
303
+ user = fetch_value(response, :user)
304
+ email = fetch_value(user, :email).to_s.downcase
305
+ return unless Routes::EMAIL_PATTERN.match?(email)
306
+
307
+ otp = email_otp_generate(config, email: email, type: "email-verification", ctx: ctx)
308
+ email_otp_store(ctx, config, email: email, type: "email-verification", otp: otp)
309
+ email_otp_deliver(config, {email: email, otp: otp, type: "email-verification"}, ctx)
310
+ nil
311
+ end
312
+
313
+ def email_otp_password_reset_request(ctx, config)
314
+ body = normalize_hash(ctx.body)
315
+ email = body[:email].to_s.downcase
316
+ otp = email_otp_resolve(ctx, config, email: email, type: "forget-password")
317
+
318
+ found = ctx.context.internal_adapter.find_user_by_email(email)
319
+ unless found
320
+ ctx.context.internal_adapter.delete_verification_by_identifier(email_otp_identifier(email, "forget-password"))
321
+ return ctx.json({success: true})
322
+ end
323
+
324
+ email_otp_deliver(config, {email: email, otp: otp, type: "forget-password"}, ctx)
325
+ ctx.json({success: true})
326
+ end
327
+
328
+ def email_otp_send_verification(ctx, config, email:, type:)
329
+ otp = email_otp_resolve(ctx, config, email: email, type: type)
330
+ found = ctx.context.internal_adapter.find_user_by_email(email)
331
+
332
+ unless found
333
+ if type == "sign-in" && !config[:disable_sign_up]
334
+ # Upstream allows sign-in OTP creation for new users when sign-up is enabled.
335
+ else
336
+ ctx.context.internal_adapter.delete_verification_by_identifier(email_otp_identifier(email, type))
337
+ return
338
+ end
339
+ end
340
+
341
+ email_otp_deliver(config, {email: email, otp: otp, type: type}, ctx)
342
+ end
343
+
344
+ def email_otp_store(ctx, config, email:, type:, otp:)
345
+ stored = email_otp_stored_value(ctx, config, otp)
346
+ identifier = email_otp_identifier(email, type)
347
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
348
+ ctx.context.internal_adapter.create_verification_value(
349
+ identifier: identifier,
350
+ value: "#{stored}:0",
351
+ expiresAt: Time.now + config[:expires_in].to_i
352
+ )
353
+ end
354
+
355
+ def email_otp_resolve(ctx, config, email:, type:, identifier_email: email)
356
+ if config[:resend_strategy].to_s == "reuse"
357
+ reused = email_otp_reuse(ctx, config, email: identifier_email, type: type)
358
+ return reused if reused
359
+ end
360
+
361
+ otp = email_otp_generate(config, email: email, type: type, ctx: ctx)
362
+ email_otp_store(ctx, config, email: identifier_email, type: type, otp: otp)
363
+ otp
364
+ end
365
+
366
+ def email_otp_reuse(ctx, config, email:, type:)
367
+ identifier = email_otp_identifier(email, type)
368
+ verification = ctx.context.internal_adapter.find_verification_value(identifier)
369
+ return nil unless verification && !Routes.expired_time?(verification["expiresAt"])
370
+
371
+ stored_otp, attempts = email_otp_split(verification["value"])
372
+ return nil if attempts.to_i >= config[:allowed_attempts].to_i
373
+
374
+ plain = email_otp_plain_value(ctx, config, stored_otp)
375
+ return nil unless plain
376
+
377
+ ctx.context.internal_adapter.update_verification_value(verification["id"], expiresAt: Time.now + config[:expires_in].to_i)
378
+ plain
379
+ end
380
+
381
+ def email_otp_verify!(ctx, config, email:, type:, otp:, consume: true)
382
+ verification = ctx.context.internal_adapter.find_verification_value(email_otp_identifier(email, type))
383
+ raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"]) unless verification
384
+
385
+ if Routes.expired_time?(verification["expiresAt"])
386
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
387
+ raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["OTP_EXPIRED"])
388
+ end
389
+
390
+ otp_value, attempts = email_otp_split(verification["value"])
391
+ attempts_count = attempts.to_i
392
+ if attempts_count >= config[:allowed_attempts].to_i
393
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
394
+ raise APIError.new("FORBIDDEN", message: EMAIL_OTP_ERROR_CODES["TOO_MANY_ATTEMPTS"])
395
+ end
396
+
397
+ ctx.context.internal_adapter.delete_verification_value(verification["id"]) if consume
398
+ unless email_otp_matches?(ctx, config, otp_value, otp)
399
+ if consume
400
+ ctx.context.internal_adapter.create_verification_value(
401
+ identifier: email_otp_identifier(email, type),
402
+ value: "#{otp_value}:#{attempts_count + 1}",
403
+ expiresAt: verification["expiresAt"]
404
+ )
405
+ else
406
+ ctx.context.internal_adapter.update_verification_value(verification["id"], value: "#{otp_value}:#{attempts_count + 1}")
407
+ end
408
+ raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"])
409
+ end
410
+
411
+ true
412
+ end
413
+
414
+ def email_otp_generate(config, email:, type:, ctx:)
415
+ generator = config[:generate_otp]
416
+ generated = generator.call({email: email, type: type}, ctx) if generator.respond_to?(:call)
417
+ return generated.to_s if generated && !generated.to_s.empty?
418
+
419
+ Array.new(config[:otp_length].to_i) { SecureRandom.random_number(10).to_s }.join
420
+ end
421
+
422
+ def email_otp_sign_up_user_data(body, email)
423
+ reserved = %i[email otp name image callback_url callbackURL callbackUrl]
424
+ additional = body.reject { |key, _value| reserved.include?(key.to_sym) }
425
+ additional = additional.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value }
426
+ additional.merge(
427
+ "email" => email,
428
+ "emailVerified" => true,
429
+ "name" => body[:name].to_s,
430
+ "image" => body[:image]
431
+ )
432
+ end
433
+
434
+ def email_otp_stored_value(ctx, config, otp)
435
+ storage = config[:store_otp]
436
+ return Crypto.sha256(otp, encoding: :base64url) if storage.to_s == "hashed"
437
+ return Crypto.symmetric_encrypt(key: ctx.context.secret, data: otp) if storage.to_s == "encrypted"
438
+
439
+ if storage.is_a?(Hash)
440
+ return storage[:hash].call(otp) if storage[:hash].respond_to?(:call)
441
+ return storage[:encrypt].call(otp) if storage[:encrypt].respond_to?(:call)
442
+ end
443
+
444
+ otp
445
+ end
446
+
447
+ def email_otp_matches?(ctx, config, stored_otp, otp)
448
+ storage = config[:store_otp]
449
+ actual, expected = if storage.to_s == "hashed"
450
+ [Crypto.sha256(otp, encoding: :base64url), stored_otp]
451
+ elsif storage.to_s == "encrypted"
452
+ [Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored_otp), otp]
453
+ elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
454
+ [storage[:hash].call(otp), stored_otp]
455
+ elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
456
+ [storage[:decrypt].call(stored_otp), otp]
457
+ else
458
+ [otp, stored_otp]
459
+ end
460
+ return false unless actual
461
+ return false unless actual.to_s.bytesize == expected.to_s.bytesize
462
+
463
+ Crypto.constant_time_compare(actual.to_s, expected.to_s)
464
+ end
465
+
466
+ def email_otp_plain_value(ctx, config, stored_otp)
467
+ storage = config[:store_otp]
468
+ return stored_otp if storage.to_s == "plain" || storage.nil?
469
+ return Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored_otp) if storage.to_s == "encrypted"
470
+ return storage[:decrypt].call(stored_otp) if storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
471
+
472
+ nil
473
+ end
474
+
475
+ def email_otp_deliver(config, data, ctx)
476
+ sender = config[:send_verification_otp]
477
+ sender.call(data, ctx) if sender.respond_to?(:call)
478
+ end
479
+
480
+ def email_otp_identifier(email, type)
481
+ "#{type}-otp-#{email}"
482
+ end
483
+
484
+ def email_otp_split(value)
485
+ string = value.to_s
486
+ index = string.rindex(":")
487
+ return [string, ""] unless index
488
+
489
+ [string[0...index], string[(index + 1)..]]
490
+ end
491
+
492
+ def validate_email_otp_email!(email)
493
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email)
494
+ end
495
+
496
+ def validate_email_otp_type!(type)
497
+ return if %w[email-verification sign-in forget-password change-email].include?(type)
498
+
499
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"])
500
+ end
501
+
502
+ def email_otp_change_email_enabled!(config)
503
+ return if config.dig(:change_email, :enabled)
504
+
505
+ raise APIError.new("BAD_REQUEST", message: "Change email with OTP is disabled")
506
+ end
507
+
508
+ def call_email_verification_option(ctx, key, user)
509
+ callback = ctx.context.options.email_verification[key]
510
+ callback.call(user, ctx.request) if callback.respond_to?(:call)
511
+ end
512
+
513
+ def email_otp_rate_limits(config)
514
+ rate_limit = normalize_hash(config[:rate_limit] || {})
515
+ window = rate_limit[:window] || 60
516
+ max = rate_limit[:max] || 3
517
+ %w[
518
+ /email-otp/send-verification-otp
519
+ /email-otp/check-verification-otp
520
+ /email-otp/verify-email
521
+ /sign-in/email-otp
522
+ /email-otp/request-password-reset
523
+ /email-otp/reset-password
524
+ /forget-password/email-otp
525
+ /email-otp/request-email-change
526
+ /email-otp/change-email
527
+ ].map do |path|
528
+ {
529
+ path_matcher: ->(request_path) { request_path == path },
530
+ window: window,
531
+ max: max
532
+ }
533
+ end
534
+ end
535
+ end
536
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/utils"
4
+ require "uri"
5
+
6
+ module BetterAuth
7
+ module Plugins
8
+ module_function
9
+
10
+ def expo(options = {})
11
+ config = normalize_hash(options)
12
+ Plugin.new(
13
+ id: "expo",
14
+ init: ->(_ctx) { expo_development_environment? ? {options: {trusted_origins: ["exp://"]}} : nil },
15
+ on_request: expo_on_request(config),
16
+ hooks: {
17
+ after: [
18
+ {
19
+ matcher: ->(ctx) { %w[/callback /oauth2/callback /magic-link/verify /verify-email].any? { |path| ctx.path.to_s.start_with?(path) } },
20
+ handler: ->(ctx) { expo_inject_cookie_into_deep_link(ctx) }
21
+ }
22
+ ]
23
+ },
24
+ endpoints: {
25
+ expo_authorization_proxy: expo_authorization_proxy_endpoint
26
+ },
27
+ options: config
28
+ )
29
+ end
30
+
31
+ def expo_authorization_proxy_endpoint
32
+ Endpoint.new(path: "/expo-authorization-proxy", method: "GET") do |ctx|
33
+ authorization_url = ctx.query[:authorizationURL] || ctx.query["authorizationURL"] || ctx.query[:authorization_url] || ctx.query["authorization_url"]
34
+ oauth_state = ctx.query[:oauthState] || ctx.query["oauthState"] || ctx.query[:oauth_state] || ctx.query["oauth_state"]
35
+ raise APIError.new("BAD_REQUEST", message: "Unexpected error") if authorization_url.to_s.empty?
36
+
37
+ if oauth_state
38
+ cookie = ctx.context.create_auth_cookie("oauth_state", max_age: 600)
39
+ ctx.set_cookie(cookie.name, oauth_state, cookie.attributes)
40
+ else
41
+ state = URI.parse(authorization_url).then { |uri| Rack::Utils.parse_query(uri.query)["state"] }
42
+ raise APIError.new("BAD_REQUEST", message: "Unexpected error") if state.to_s.empty?
43
+
44
+ cookie = ctx.context.create_auth_cookie("state", max_age: 300)
45
+ ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
46
+ end
47
+ [302, ctx.response_headers.merge("location" => authorization_url), [""]]
48
+ rescue URI::InvalidURIError
49
+ raise APIError.new("BAD_REQUEST", message: "Unexpected error")
50
+ end
51
+ end
52
+
53
+ def expo_on_request(config)
54
+ lambda do |request, _context|
55
+ next if config[:disable_origin_override] || request.get_header("HTTP_ORIGIN")
56
+
57
+ expo_origin = request.get_header("HTTP_EXPO_ORIGIN")
58
+ next unless expo_origin
59
+
60
+ env = request.env.dup
61
+ env["HTTP_ORIGIN"] = expo_origin
62
+ {request: Rack::Request.new(env)}
63
+ end
64
+ end
65
+
66
+ def expo_inject_cookie_into_deep_link(ctx)
67
+ location = ctx.response_headers["location"]
68
+ cookie = ctx.response_headers["set-cookie"]
69
+ return unless location && cookie
70
+ return if location.include?("/oauth-proxy-callback")
71
+
72
+ uri = URI.parse(location)
73
+ return if %w[http https].include?(uri.scheme)
74
+ return unless ctx.context.trusted_origin?(location)
75
+
76
+ query = Rack::Utils.parse_query(uri.query)
77
+ query["cookie"] = cookie
78
+ uri.query = URI.encode_www_form(query)
79
+ ctx.set_header("location", uri.to_s)
80
+ rescue URI::InvalidURIError
81
+ nil
82
+ end
83
+
84
+ def expo_development_environment?
85
+ [ENV["RACK_ENV"], ENV["RAILS_ENV"], ENV["APP_ENV"]].include?("development")
86
+ end
87
+ end
88
+ end