better_auth 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +10 -7
  5. data/lib/better_auth/adapters/memory.rb +57 -11
  6. data/lib/better_auth/adapters/sql.rb +123 -20
  7. data/lib/better_auth/api.rb +114 -9
  8. data/lib/better_auth/async.rb +70 -0
  9. data/lib/better_auth/configuration.rb +97 -7
  10. data/lib/better_auth/context.rb +165 -12
  11. data/lib/better_auth/cookies.rb +6 -4
  12. data/lib/better_auth/core.rb +2 -0
  13. data/lib/better_auth/crypto/jwe.rb +27 -5
  14. data/lib/better_auth/crypto.rb +32 -0
  15. data/lib/better_auth/database_hooks.rb +8 -8
  16. data/lib/better_auth/deprecate.rb +28 -0
  17. data/lib/better_auth/endpoint.rb +92 -5
  18. data/lib/better_auth/error.rb +8 -1
  19. data/lib/better_auth/host.rb +166 -0
  20. data/lib/better_auth/instrumentation.rb +74 -0
  21. data/lib/better_auth/logger.rb +31 -0
  22. data/lib/better_auth/middleware/origin_check.rb +2 -2
  23. data/lib/better_auth/oauth2.rb +94 -0
  24. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  25. data/lib/better_auth/plugins/admin.rb +344 -16
  26. data/lib/better_auth/plugins/anonymous.rb +37 -3
  27. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  28. data/lib/better_auth/plugins/dub.rb +148 -0
  29. data/lib/better_auth/plugins/email_otp.rb +261 -19
  30. data/lib/better_auth/plugins/expo.rb +17 -1
  31. data/lib/better_auth/plugins/generic_oauth.rb +67 -35
  32. data/lib/better_auth/plugins/jwt.rb +37 -4
  33. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  34. data/lib/better_auth/plugins/magic_link.rb +66 -3
  35. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  36. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  37. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  38. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  39. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  40. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  41. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  42. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  43. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  44. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  45. data/lib/better_auth/plugins/mcp.rb +111 -263
  46. data/lib/better_auth/plugins/multi_session.rb +61 -3
  47. data/lib/better_auth/plugins/oauth_protocol.rb +173 -30
  48. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  49. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  50. data/lib/better_auth/plugins/one_tap.rb +7 -2
  51. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  52. data/lib/better_auth/plugins/open_api.rb +163 -318
  53. data/lib/better_auth/plugins/organization/schema.rb +6 -0
  54. data/lib/better_auth/plugins/organization.rb +186 -56
  55. data/lib/better_auth/plugins/phone_number.rb +141 -6
  56. data/lib/better_auth/plugins/siwe.rb +69 -3
  57. data/lib/better_auth/plugins/two_factor.rb +118 -41
  58. data/lib/better_auth/plugins/username.rb +57 -2
  59. data/lib/better_auth/rate_limiter.rb +38 -0
  60. data/lib/better_auth/request_state.rb +44 -0
  61. data/lib/better_auth/response.rb +42 -0
  62. data/lib/better_auth/router.rb +7 -1
  63. data/lib/better_auth/routes/account.rb +220 -42
  64. data/lib/better_auth/routes/email_verification.rb +98 -14
  65. data/lib/better_auth/routes/password.rb +126 -8
  66. data/lib/better_auth/routes/session.rb +128 -13
  67. data/lib/better_auth/routes/sign_in.rb +26 -2
  68. data/lib/better_auth/routes/sign_out.rb +13 -1
  69. data/lib/better_auth/routes/sign_up.rb +70 -4
  70. data/lib/better_auth/routes/social.rb +132 -7
  71. data/lib/better_auth/routes/user.rb +228 -20
  72. data/lib/better_auth/routes/validation.rb +50 -0
  73. data/lib/better_auth/secret_config.rb +115 -0
  74. data/lib/better_auth/session.rb +13 -2
  75. data/lib/better_auth/url_helpers.rb +206 -0
  76. data/lib/better_auth/version.rb +1 -1
  77. data/lib/better_auth.rb +12 -0
  78. metadata +23 -1
@@ -65,7 +65,38 @@ module BetterAuth
65
65
  end
66
66
 
67
67
  def sign_in_phone_number_endpoint(config)
68
- Endpoint.new(path: "/sign-in/phone-number", method: "POST") do |ctx|
68
+ Endpoint.new(
69
+ path: "/sign-in/phone-number",
70
+ method: "POST",
71
+ metadata: {
72
+ openapi: {
73
+ operationId: "signInPhoneNumber",
74
+ description: "Sign in with phone number and password",
75
+ requestBody: OpenAPI.json_request_body(
76
+ OpenAPI.object_schema(
77
+ {
78
+ phoneNumber: {type: "string"},
79
+ password: {type: "string"},
80
+ rememberMe: {type: ["boolean", "null"]}
81
+ },
82
+ required: ["phoneNumber", "password"]
83
+ )
84
+ ),
85
+ responses: {
86
+ "200" => OpenAPI.json_response(
87
+ "Signed in",
88
+ OpenAPI.object_schema(
89
+ {
90
+ token: {type: "string"},
91
+ user: {type: "object", "$ref": "#/components/schemas/User"}
92
+ },
93
+ required: ["token", "user"]
94
+ )
95
+ )
96
+ }
97
+ }
98
+ }
99
+ ) do |ctx|
69
100
  body = normalize_hash(ctx.body)
70
101
  phone_number = body[:phone_number].to_s
71
102
  password = body[:password].to_s
@@ -101,7 +132,35 @@ module BetterAuth
101
132
  end
102
133
 
103
134
  def send_phone_number_otp_endpoint(config)
104
- Endpoint.new(path: "/phone-number/send-otp", method: "POST") do |ctx|
135
+ Endpoint.new(
136
+ path: "/phone-number/send-otp",
137
+ method: "POST",
138
+ metadata: {
139
+ openapi: {
140
+ operationId: "sendPhoneNumberOTP",
141
+ description: "Send a phone number OTP",
142
+ requestBody: OpenAPI.json_request_body(
143
+ OpenAPI.object_schema(
144
+ {
145
+ phoneNumber: {type: "string"}
146
+ },
147
+ required: ["phoneNumber"]
148
+ )
149
+ ),
150
+ responses: {
151
+ "200" => OpenAPI.json_response(
152
+ "OTP sent",
153
+ OpenAPI.object_schema(
154
+ {
155
+ message: {type: "string"}
156
+ },
157
+ required: ["message"]
158
+ )
159
+ )
160
+ }
161
+ }
162
+ }
163
+ ) do |ctx|
105
164
  sender = config[:send_otp]
106
165
  unless sender.respond_to?(:call)
107
166
  raise APIError.new("NOT_IMPLEMENTED", message: PHONE_NUMBER_ERROR_CODES["SEND_OTP_NOT_IMPLEMENTED"])
@@ -118,7 +177,40 @@ module BetterAuth
118
177
  end
119
178
 
120
179
  def verify_phone_number_endpoint(config)
121
- Endpoint.new(path: "/phone-number/verify", method: "POST") do |ctx|
180
+ Endpoint.new(
181
+ path: "/phone-number/verify",
182
+ method: "POST",
183
+ metadata: {
184
+ openapi: {
185
+ operationId: "verifyPhoneNumber",
186
+ description: "Verify a phone number OTP",
187
+ requestBody: OpenAPI.json_request_body(
188
+ OpenAPI.object_schema(
189
+ {
190
+ phoneNumber: {type: "string"},
191
+ code: {type: "string"},
192
+ updatePhoneNumber: {type: ["boolean", "null"]},
193
+ disableSession: {type: ["boolean", "null"]}
194
+ },
195
+ required: ["phoneNumber", "code"]
196
+ )
197
+ ),
198
+ responses: {
199
+ "200" => OpenAPI.json_response(
200
+ "Phone number verified",
201
+ OpenAPI.object_schema(
202
+ {
203
+ status: {type: "boolean"},
204
+ token: {type: ["string", "null"]},
205
+ user: {type: "object", "$ref": "#/components/schemas/User"}
206
+ },
207
+ required: ["status", "user"]
208
+ )
209
+ )
210
+ }
211
+ }
212
+ }
213
+ ) do |ctx|
122
214
  body = normalize_hash(ctx.body)
123
215
  phone_number = body[:phone_number].to_s
124
216
  code = body[:code].to_s
@@ -163,7 +255,27 @@ module BetterAuth
163
255
  end
164
256
 
165
257
  def request_password_reset_phone_number_endpoint(config)
166
- Endpoint.new(path: "/phone-number/request-password-reset", method: "POST") do |ctx|
258
+ Endpoint.new(
259
+ path: "/phone-number/request-password-reset",
260
+ method: "POST",
261
+ metadata: {
262
+ openapi: {
263
+ operationId: "requestPasswordResetPhoneNumber",
264
+ description: "Request a phone number password reset OTP",
265
+ requestBody: OpenAPI.json_request_body(
266
+ OpenAPI.object_schema(
267
+ {
268
+ phoneNumber: {type: "string"}
269
+ },
270
+ required: ["phoneNumber"]
271
+ )
272
+ ),
273
+ responses: {
274
+ "200" => OpenAPI.json_response("Password reset OTP requested", OpenAPI.status_response_schema)
275
+ }
276
+ }
277
+ }
278
+ ) do |ctx|
167
279
  body = normalize_hash(ctx.body)
168
280
  phone_number = body[:phone_number].to_s
169
281
  user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}])
@@ -179,7 +291,29 @@ module BetterAuth
179
291
  end
180
292
 
181
293
  def reset_password_phone_number_endpoint(config)
182
- Endpoint.new(path: "/phone-number/reset-password", method: "POST") do |ctx|
294
+ Endpoint.new(
295
+ path: "/phone-number/reset-password",
296
+ method: "POST",
297
+ metadata: {
298
+ openapi: {
299
+ operationId: "resetPasswordPhoneNumber",
300
+ description: "Reset a password with a phone number OTP",
301
+ requestBody: OpenAPI.json_request_body(
302
+ OpenAPI.object_schema(
303
+ {
304
+ phoneNumber: {type: "string"},
305
+ otp: {type: "string"},
306
+ newPassword: {type: "string"}
307
+ },
308
+ required: ["phoneNumber", "otp", "newPassword"]
309
+ )
310
+ ),
311
+ responses: {
312
+ "200" => OpenAPI.json_response("Password reset", OpenAPI.status_response_schema)
313
+ }
314
+ }
315
+ }
316
+ ) do |ctx|
183
317
  body = normalize_hash(ctx.body)
184
318
  phone_number = body[:phone_number].to_s
185
319
  otp = body[:otp].to_s
@@ -225,7 +359,8 @@ module BetterAuth
225
359
  "phoneNumber" => phone_number,
226
360
  "phoneNumberVerified" => true,
227
361
  "emailVerified" => false
228
- )
362
+ ),
363
+ context: ctx
229
364
  )
230
365
  end
231
366
 
@@ -24,7 +24,37 @@ module BetterAuth
24
24
  end
25
25
 
26
26
  def get_siwe_nonce_endpoint(config)
27
- Endpoint.new(path: "/siwe/nonce", method: "POST", body_schema: ->(body) { siwe_nonce_body(body) }) do |ctx|
27
+ Endpoint.new(
28
+ path: "/siwe/nonce",
29
+ method: "POST",
30
+ body_schema: ->(body) { siwe_nonce_body(body) },
31
+ metadata: {
32
+ openapi: {
33
+ operationId: "getSiweNonce",
34
+ description: "Generate a nonce for Sign-In with Ethereum",
35
+ requestBody: OpenAPI.json_request_body(
36
+ OpenAPI.object_schema(
37
+ {
38
+ walletAddress: {type: "string"},
39
+ chainId: {type: ["number", "string", "null"]}
40
+ },
41
+ required: ["walletAddress"]
42
+ )
43
+ ),
44
+ responses: {
45
+ "200" => OpenAPI.json_response(
46
+ "SIWE nonce",
47
+ OpenAPI.object_schema(
48
+ {
49
+ nonce: {type: "string"}
50
+ },
51
+ required: ["nonce"]
52
+ )
53
+ )
54
+ }
55
+ }
56
+ }
57
+ ) do |ctx|
28
58
  body = normalize_hash(ctx.body)
29
59
  wallet_address = siwe_normalize_wallet!(body[:wallet_address])
30
60
  chain_id = siwe_chain_id(body[:chain_id])
@@ -42,7 +72,42 @@ module BetterAuth
42
72
  end
43
73
 
44
74
  def verify_siwe_message_endpoint(config)
45
- Endpoint.new(path: "/siwe/verify", method: "POST", body_schema: ->(body) { siwe_verify_body(body, config) }) do |ctx|
75
+ Endpoint.new(
76
+ path: "/siwe/verify",
77
+ method: "POST",
78
+ body_schema: ->(body) { siwe_verify_body(body, config) },
79
+ metadata: {
80
+ openapi: {
81
+ operationId: "verifySiweMessage",
82
+ description: "Verify a Sign-In with Ethereum message",
83
+ requestBody: OpenAPI.json_request_body(
84
+ OpenAPI.object_schema(
85
+ {
86
+ walletAddress: {type: "string"},
87
+ chainId: {type: ["number", "string", "null"]},
88
+ message: {type: "string"},
89
+ signature: {type: "string"},
90
+ email: {type: ["string", "null"]}
91
+ },
92
+ required: ["walletAddress", "message", "signature"]
93
+ )
94
+ ),
95
+ responses: {
96
+ "200" => OpenAPI.json_response(
97
+ "SIWE message verified",
98
+ OpenAPI.object_schema(
99
+ {
100
+ token: {type: "string"},
101
+ success: {type: "boolean"},
102
+ user: {type: "object"}
103
+ },
104
+ required: ["token", "success", "user"]
105
+ )
106
+ )
107
+ }
108
+ }
109
+ }
110
+ ) do |ctx|
46
111
  body = normalize_hash(ctx.body)
47
112
  wallet_address = siwe_normalize_wallet!(body[:wallet_address])
48
113
  chain_id = siwe_chain_id(body[:chain_id])
@@ -203,7 +268,8 @@ module BetterAuth
203
268
  ctx.context.internal_adapter.create_user(
204
269
  name: ens[:name] || wallet_address,
205
270
  email: (anonymous == false && !email.empty?) ? email : "#{wallet_address}@#{domain}",
206
- image: ens[:avatar] || ""
271
+ image: ens[:avatar] || "",
272
+ context: ctx
207
273
  )
208
274
  end
209
275
 
@@ -24,6 +24,7 @@ module BetterAuth
24
24
  TRUST_DEVICE_COOKIE_NAME = "trust_device"
25
25
  TRUST_DEVICE_COOKIE_MAX_AGE = 30 * 24 * 60 * 60
26
26
  TWO_FACTOR_COOKIE_MAX_AGE = 10 * 60
27
+ TWO_FACTOR_MODEL = "twoFactor"
27
28
 
28
29
  module_function
29
30
 
@@ -39,6 +40,8 @@ module BetterAuth
39
40
  config[:backup_code_options] = {store_backup_codes: "encrypted"}.merge(normalize_hash(config[:backup_code_options]))
40
41
  config[:otp_options] = normalize_hash(config[:otp_options])
41
42
  config[:totp_options] = normalize_hash(config[:totp_options])
43
+ config[:backup_code_options][:allow_passwordless] = config[:allow_passwordless] unless config[:backup_code_options].key?(:allow_passwordless)
44
+ config[:totp_options][:allow_passwordless] = config[:allow_passwordless] unless config[:totp_options].key?(:allow_passwordless)
42
45
 
43
46
  Plugin.new(
44
47
  id: "two-factor",
@@ -62,7 +65,7 @@ module BetterAuth
62
65
  }
63
66
  ]
64
67
  },
65
- schema: two_factor_schema(config[:schema]),
68
+ schema: two_factor_schema(config),
66
69
  rate_limit: [
67
70
  {
68
71
  path_matcher: ->(path) { path.start_with?("/two-factor/") },
@@ -76,13 +79,13 @@ module BetterAuth
76
79
  end
77
80
 
78
81
  def two_factor_enable_endpoint(config)
79
- Endpoint.new(path: "/two-factor/enable", method: "POST") do |ctx|
80
- session = Routes.current_session(ctx, sensitive: true)
82
+ Endpoint.new(path: "/two-factor/enable", method: "POST", metadata: two_factor_openapi("enableTwoFactor", "Enable two factor authentication", two_factor_enable_response_schema)) do |ctx|
83
+ session = Routes.current_session(ctx)
81
84
  body = normalize_hash(ctx.body)
82
- two_factor_check_password!(ctx, session[:user]["id"], body[:password])
85
+ two_factor_check_password!(ctx, session[:user]["id"], body[:password], allow_passwordless: config[:allow_passwordless])
83
86
 
84
87
  secret = two_factor_generate_secret
85
- backup = two_factor_generate_backup_codes(ctx.context.secret, config[:backup_code_options])
88
+ backup = two_factor_generate_backup_codes(ctx.context.secret_config, config[:backup_code_options])
86
89
  if config[:skip_verification_on_enable]
87
90
  updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: true)
88
91
  new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
@@ -90,14 +93,18 @@ module BetterAuth
90
93
  ctx.context.internal_adapter.delete_session(session[:session]["token"])
91
94
  end
92
95
 
93
- ctx.context.adapter.delete_many(model: config[:two_factor_table], where: [{field: "userId", value: session[:user]["id"]}])
96
+ existing = two_factor_record(ctx, config, session[:user]["id"])
97
+ verified = (!!existing && existing["verified"] != false) || !!config[:skip_verification_on_enable]
98
+ ctx.context.adapter.delete_many(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: session[:user]["id"]}])
94
99
  ctx.context.adapter.create(
95
- model: config[:two_factor_table],
100
+ model: TWO_FACTOR_MODEL,
96
101
  data: {
97
- secret: Crypto.symmetric_encrypt(key: ctx.context.secret, data: secret),
102
+ secret: Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: secret),
98
103
  backupCodes: backup[:stored],
99
- userId: session[:user]["id"]
100
- }
104
+ userId: session[:user]["id"],
105
+ verified: verified
106
+ },
107
+ force_allow_id: true
101
108
  )
102
109
 
103
110
  ctx.json({
@@ -108,13 +115,13 @@ module BetterAuth
108
115
  end
109
116
 
110
117
  def two_factor_disable_endpoint(config)
111
- Endpoint.new(path: "/two-factor/disable", method: "POST") do |ctx|
112
- session = Routes.current_session(ctx, sensitive: true)
118
+ Endpoint.new(path: "/two-factor/disable", method: "POST", metadata: two_factor_openapi("disableTwoFactor", "Disable two factor authentication", OpenAPI.status_response_schema)) do |ctx|
119
+ session = Routes.current_session(ctx)
113
120
  body = normalize_hash(ctx.body)
114
- two_factor_check_password!(ctx, session[:user]["id"], body[:password])
121
+ two_factor_check_password!(ctx, session[:user]["id"], body[:password], allow_passwordless: config[:allow_passwordless])
115
122
 
116
123
  updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: false)
117
- ctx.context.adapter.delete(model: config[:two_factor_table], where: [{field: "userId", value: updated_user["id"]}])
124
+ ctx.context.adapter.delete(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: updated_user["id"]}])
118
125
  new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
119
126
  Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
120
127
  ctx.context.internal_adapter.delete_session(session[:session]["token"])
@@ -131,7 +138,7 @@ module BetterAuth
131
138
  end
132
139
 
133
140
  def two_factor_generate_totp_endpoint(config)
134
- Endpoint.new(path: "/totp/generate", method: "POST") do |ctx|
141
+ Endpoint.new(path: "/totp/generate", method: "POST", metadata: two_factor_openapi("generateTOTP", "Generate a TOTP code", OpenAPI.object_schema({code: {type: "string"}}, required: ["code"]))) do |ctx|
135
142
  two_factor_totp_enabled!(config)
136
143
  body = normalize_hash(ctx.body)
137
144
  ctx.json({code: two_factor_totp(body[:secret], options: config[:totp_options])})
@@ -139,30 +146,41 @@ module BetterAuth
139
146
  end
140
147
 
141
148
  def two_factor_get_totp_uri_endpoint(config)
142
- Endpoint.new(path: "/two-factor/get-totp-uri", method: "POST") do |ctx|
149
+ Endpoint.new(path: "/two-factor/get-totp-uri", method: "POST", metadata: two_factor_openapi("getTOTPURI", "Get the TOTP URI", OpenAPI.object_schema({totpURI: {type: "string"}}, required: ["totpURI"]))) do |ctx|
143
150
  two_factor_totp_enabled!(config)
144
- session = Routes.current_session(ctx, sensitive: true)
145
- two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password])
151
+ session = Routes.current_session(ctx)
152
+ two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password], allow_passwordless: config[:totp_options][:allow_passwordless])
146
153
  record = two_factor_record(ctx, config, session[:user]["id"])
147
154
  raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record
148
155
 
149
- secret = Crypto.symmetric_decrypt(key: ctx.context.secret, data: record["secret"])
156
+ secret = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: record["secret"])
150
157
  ctx.json({totpURI: two_factor_totp_uri(secret, issuer: config[:issuer] || ctx.context.app_name, account: session[:user]["email"], options: config[:totp_options])})
151
158
  end
152
159
  end
153
160
 
154
161
  def two_factor_verify_totp_endpoint(config)
155
- Endpoint.new(path: "/two-factor/verify-totp", method: "POST") do |ctx|
162
+ Endpoint.new(path: "/two-factor/verify-totp", method: "POST", metadata: two_factor_openapi("verifyTOTP", "Verify a TOTP code", two_factor_verification_response_schema)) do |ctx|
156
163
  two_factor_totp_enabled!(config)
157
164
  body = normalize_hash(ctx.body)
158
165
  data = two_factor_verification_context(ctx, config)
159
166
  record = two_factor_record(ctx, config, data[:session][:user]["id"])
160
167
  raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record
168
+ if !data[:session][:session] && record["verified"] == false
169
+ raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"])
170
+ end
161
171
 
162
- secret = Crypto.symmetric_decrypt(key: ctx.context.secret, data: record["secret"])
172
+ secret = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: record["secret"])
163
173
  raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"]) unless two_factor_totp_valid?(secret, body[:code], options: config[:totp_options])
164
174
 
165
- if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
175
+ if record["verified"] != true
176
+ if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
177
+ updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true)
178
+ new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
179
+ ctx.context.internal_adapter.delete_session(data[:session][:session]["token"])
180
+ Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
181
+ end
182
+ ctx.context.adapter.update(model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], update: {verified: true})
183
+ elsif !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
166
184
  updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true)
167
185
  new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
168
186
  ctx.context.internal_adapter.delete_session(data[:session][:session]["token"])
@@ -173,7 +191,7 @@ module BetterAuth
173
191
  end
174
192
 
175
193
  def two_factor_send_otp_endpoint(config)
176
- Endpoint.new(path: "/two-factor/send-otp", method: "POST") do |ctx|
194
+ Endpoint.new(path: "/two-factor/send-otp", method: "POST", metadata: two_factor_openapi("sendTwoFactorOTP", "Send a two factor OTP", OpenAPI.status_response_schema)) do |ctx|
177
195
  otp_config = config[:otp_options]
178
196
  sender = otp_config[:send_otp]
179
197
  unless sender.respond_to?(:call)
@@ -194,7 +212,7 @@ module BetterAuth
194
212
  end
195
213
 
196
214
  def two_factor_verify_otp_endpoint(config)
197
- Endpoint.new(path: "/two-factor/verify-otp", method: "POST") do |ctx|
215
+ Endpoint.new(path: "/two-factor/verify-otp", method: "POST", metadata: two_factor_openapi("verifyTwoFactorOTP", "Verify a two factor OTP", two_factor_verification_response_schema)) do |ctx|
198
216
  body = normalize_hash(ctx.body)
199
217
  data = two_factor_verification_context(ctx, config)
200
218
  verification = ctx.context.internal_adapter.find_verification_value("2fa-otp-#{data[:key]}")
@@ -228,21 +246,21 @@ module BetterAuth
228
246
  end
229
247
 
230
248
  def two_factor_verify_backup_code_endpoint(config)
231
- Endpoint.new(path: "/two-factor/verify-backup-code", method: "POST") do |ctx|
249
+ Endpoint.new(path: "/two-factor/verify-backup-code", method: "POST", metadata: two_factor_openapi("verifyBackupCode", "Verify a two factor backup code", two_factor_verification_response_schema)) do |ctx|
232
250
  body = normalize_hash(ctx.body)
233
251
  data = two_factor_verification_context(ctx, config)
234
252
  record = two_factor_record(ctx, config, data[:session][:user]["id"])
235
253
  raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record
236
254
 
237
- codes = two_factor_read_backup_codes(ctx.context.secret, record["backupCodes"], config[:backup_code_options])
255
+ codes = two_factor_read_backup_codes(ctx.context.secret_config, record["backupCodes"], config[:backup_code_options])
238
256
  unless codes.include?(body[:code].to_s)
239
257
  raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_BACKUP_CODE"])
240
258
  end
241
259
 
242
260
  remaining = codes.reject { |code| code == body[:code].to_s }
243
- stored = two_factor_store_backup_codes(ctx.context.secret, remaining, config[:backup_code_options])
261
+ stored = two_factor_store_backup_codes(ctx.context.secret_config, remaining, config[:backup_code_options])
244
262
  updated = ctx.context.adapter.update(
245
- model: config[:two_factor_table],
263
+ model: TWO_FACTOR_MODEL,
246
264
  where: [{field: "id", value: record["id"]}, {field: "backupCodes", value: record["backupCodes"]}],
247
265
  update: {backupCodes: stored}
248
266
  )
@@ -253,16 +271,16 @@ module BetterAuth
253
271
  end
254
272
 
255
273
  def two_factor_generate_backup_codes_endpoint(config)
256
- Endpoint.new(path: "/two-factor/generate-backup-codes", method: "POST") do |ctx|
257
- session = Routes.current_session(ctx, sensitive: true)
274
+ Endpoint.new(path: "/two-factor/generate-backup-codes", method: "POST", metadata: two_factor_openapi("generateBackupCodes", "Generate two factor backup codes", two_factor_backup_codes_response_schema)) do |ctx|
275
+ session = Routes.current_session(ctx)
258
276
  raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless session[:user]["twoFactorEnabled"]
259
277
 
260
- two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password])
278
+ two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password], allow_passwordless: config[:backup_code_options][:allow_passwordless])
261
279
  record = two_factor_record(ctx, config, session[:user]["id"])
262
280
  raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless record
263
281
 
264
- backup = two_factor_generate_backup_codes(ctx.context.secret, config[:backup_code_options])
265
- ctx.context.adapter.update(model: config[:two_factor_table], where: [{field: "id", value: record["id"]}], update: {backupCodes: backup[:stored]})
282
+ backup = two_factor_generate_backup_codes(ctx.context.secret_config, config[:backup_code_options])
283
+ ctx.context.adapter.update(model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], update: {backupCodes: backup[:stored]})
266
284
  ctx.json({status: true, backupCodes: backup[:codes]})
267
285
  end
268
286
  end
@@ -273,11 +291,54 @@ module BetterAuth
273
291
  record = two_factor_record(ctx, config, body[:user_id])
274
292
  raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record
275
293
 
276
- ctx.json({status: true, backupCodes: two_factor_read_backup_codes(ctx.context.secret, record["backupCodes"], config[:backup_code_options])})
294
+ ctx.json({status: true, backupCodes: two_factor_read_backup_codes(ctx.context.secret_config, record["backupCodes"], config[:backup_code_options])})
277
295
  end
278
296
  end
279
297
 
280
- def two_factor_schema(custom_schema = nil)
298
+ def two_factor_openapi(operation_id, description, response_schema)
299
+ {
300
+ openapi: {
301
+ operationId: operation_id,
302
+ description: description,
303
+ responses: {
304
+ "200" => OpenAPI.json_response("Success", response_schema)
305
+ }
306
+ }
307
+ }
308
+ end
309
+
310
+ def two_factor_enable_response_schema
311
+ OpenAPI.object_schema(
312
+ {
313
+ totpURI: {type: "string"},
314
+ backupCodes: {type: "array", items: {type: "string"}}
315
+ },
316
+ required: ["totpURI", "backupCodes"]
317
+ )
318
+ end
319
+
320
+ def two_factor_verification_response_schema
321
+ OpenAPI.object_schema(
322
+ {
323
+ token: {type: ["string", "null"]},
324
+ user: {type: ["object", "null"], "$ref": "#/components/schemas/User"},
325
+ status: {type: ["boolean", "null"]}
326
+ }
327
+ )
328
+ end
329
+
330
+ def two_factor_backup_codes_response_schema
331
+ OpenAPI.object_schema(
332
+ {
333
+ status: {type: "boolean"},
334
+ backupCodes: {type: "array", items: {type: "string"}}
335
+ },
336
+ required: ["status", "backupCodes"]
337
+ )
338
+ end
339
+
340
+ def two_factor_schema(config = {})
341
+ custom_schema = config[:schema]
281
342
  base = {
282
343
  user: {
283
344
  fields: {
@@ -288,10 +349,14 @@ module BetterAuth
288
349
  fields: {
289
350
  secret: {type: "string", required: true, returned: false, index: true},
290
351
  backupCodes: {type: "string", required: true, returned: false},
291
- userId: {type: "string", required: true, returned: false, index: true, references: {model: "user", field: "id"}}
352
+ userId: {type: "string", required: true, returned: false, index: true, references: {model: "user", field: "id"}},
353
+ verified: {type: "boolean", required: false, default_value: true, input: false}
292
354
  }
293
355
  }
294
356
  }
357
+ if config[:two_factor_table] && config[:two_factor_table] != TWO_FACTOR_MODEL
358
+ base[:twoFactor][:model_name] = config[:two_factor_table].to_s
359
+ end
295
360
  deep_merge_hashes(base, normalize_hash(custom_schema || {}))
296
361
  end
297
362
 
@@ -311,7 +376,7 @@ module BetterAuth
311
376
  expiresAt: Time.now + config[:two_factor_cookie_max_age].to_i
312
377
  )
313
378
  ctx.set_signed_cookie(cookie.name, identifier, ctx.context.secret, cookie.attributes)
314
- ctx.json({twoFactorRedirect: true})
379
+ ctx.json({twoFactorRedirect: true, twoFactorMethods: two_factor_methods(ctx, config, data[:user]["id"])})
315
380
  end
316
381
 
317
382
  def two_factor_verification_context(ctx, config)
@@ -377,11 +442,23 @@ module BetterAuth
377
442
  end
378
443
 
379
444
  def two_factor_record(ctx, config, user_id)
380
- ctx.context.adapter.find_one(model: config[:two_factor_table], where: [{field: "userId", value: user_id}])
445
+ ctx.context.adapter.find_one(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: user_id}])
446
+ end
447
+
448
+ def two_factor_methods(ctx, config, user_id)
449
+ methods = []
450
+ unless config[:totp_options][:disable]
451
+ record = two_factor_record(ctx, config, user_id)
452
+ methods << "totp" if record && record["verified"] != false
453
+ end
454
+ methods << "otp" if config[:otp_options][:send_otp].respond_to?(:call)
455
+ methods
381
456
  end
382
457
 
383
- def two_factor_check_password!(ctx, user_id, password)
458
+ def two_factor_check_password!(ctx, user_id, password, allow_passwordless: false)
384
459
  account = ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" }
460
+ return if allow_passwordless && !account
461
+
385
462
  unless account && account["password"] && Routes.verify_password_value(ctx, password.to_s, account["password"])
386
463
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
387
464
  end
@@ -472,7 +549,7 @@ module BetterAuth
472
549
  if storage == "hashed"
473
550
  Crypto.sha256(code, encoding: :base64url)
474
551
  elsif storage == "encrypted"
475
- Crypto.symmetric_encrypt(key: ctx.context.secret, data: code)
552
+ Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: code)
476
553
  elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
477
554
  storage[:hash].call(code)
478
555
  elsif storage.is_a?(Hash) && storage[:encrypt].respond_to?(:call)
@@ -487,7 +564,7 @@ module BetterAuth
487
564
  expected, actual = if storage == "hashed"
488
565
  [stored, Crypto.sha256(input, encoding: :base64url)]
489
566
  elsif storage == "encrypted"
490
- [Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored), input]
567
+ [Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored), input]
491
568
  elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
492
569
  [stored, storage[:hash].call(input)]
493
570
  elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)