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,514 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "uri"
8
+
9
+ module BetterAuth
10
+ module Plugins
11
+ TWO_FACTOR_ERROR_CODES = {
12
+ "OTP_NOT_ENABLED" => "OTP not enabled",
13
+ "OTP_HAS_EXPIRED" => "OTP has expired",
14
+ "TOTP_NOT_ENABLED" => "TOTP not enabled",
15
+ "TWO_FACTOR_NOT_ENABLED" => "Two factor isn't enabled",
16
+ "BACKUP_CODES_NOT_ENABLED" => "Backup codes aren't enabled",
17
+ "INVALID_BACKUP_CODE" => "Invalid backup code",
18
+ "INVALID_CODE" => "Invalid code",
19
+ "TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE" => "Too many attempts. Please request a new code.",
20
+ "INVALID_TWO_FACTOR_COOKIE" => "Invalid two factor cookie"
21
+ }.freeze
22
+
23
+ TWO_FACTOR_COOKIE_NAME = "two_factor"
24
+ TRUST_DEVICE_COOKIE_NAME = "trust_device"
25
+ TRUST_DEVICE_COOKIE_MAX_AGE = 30 * 24 * 60 * 60
26
+ TWO_FACTOR_COOKIE_MAX_AGE = 10 * 60
27
+
28
+ module_function
29
+
30
+ def two_factor(options = {})
31
+ config = {
32
+ two_factor_table: "twoFactor",
33
+ trust_device_max_age: TRUST_DEVICE_COOKIE_MAX_AGE,
34
+ two_factor_cookie_max_age: TWO_FACTOR_COOKIE_MAX_AGE,
35
+ backup_code_options: {store_backup_codes: "encrypted"},
36
+ otp_options: {},
37
+ totp_options: {}
38
+ }.merge(normalize_hash(options))
39
+ config[:backup_code_options] = {store_backup_codes: "encrypted"}.merge(normalize_hash(config[:backup_code_options]))
40
+ config[:otp_options] = normalize_hash(config[:otp_options])
41
+ config[:totp_options] = normalize_hash(config[:totp_options])
42
+
43
+ Plugin.new(
44
+ id: "two-factor",
45
+ endpoints: {
46
+ enable_two_factor: two_factor_enable_endpoint(config),
47
+ disable_two_factor: two_factor_disable_endpoint(config),
48
+ generate_totp: two_factor_generate_totp_endpoint(config),
49
+ get_totp_uri: two_factor_get_totp_uri_endpoint(config),
50
+ verify_totp: two_factor_verify_totp_endpoint(config),
51
+ send_two_factor_otp: two_factor_send_otp_endpoint(config),
52
+ verify_two_factor_otp: two_factor_verify_otp_endpoint(config),
53
+ verify_backup_code: two_factor_verify_backup_code_endpoint(config),
54
+ generate_backup_codes: two_factor_generate_backup_codes_endpoint(config),
55
+ view_backup_codes: two_factor_view_backup_codes_endpoint(config)
56
+ },
57
+ hooks: {
58
+ after: [
59
+ {
60
+ matcher: ->(ctx) { ["/sign-in/email", "/sign-in/username", "/sign-in/phone-number"].include?(ctx.path) },
61
+ handler: ->(ctx) { two_factor_after_sign_in(ctx, config) }
62
+ }
63
+ ]
64
+ },
65
+ schema: two_factor_schema(config[:schema]),
66
+ rate_limit: [
67
+ {
68
+ path_matcher: ->(path) { path.start_with?("/two-factor/") },
69
+ window: 10,
70
+ max: 3
71
+ }
72
+ ],
73
+ error_codes: TWO_FACTOR_ERROR_CODES,
74
+ options: config
75
+ )
76
+ end
77
+
78
+ 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)
81
+ body = normalize_hash(ctx.body)
82
+ two_factor_check_password!(ctx, session[:user]["id"], body[:password])
83
+
84
+ secret = two_factor_generate_secret
85
+ backup = two_factor_generate_backup_codes(ctx.context.secret, config[:backup_code_options])
86
+ if config[:skip_verification_on_enable]
87
+ updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: true)
88
+ new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
89
+ Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
90
+ ctx.context.internal_adapter.delete_session(session[:session]["token"])
91
+ end
92
+
93
+ ctx.context.adapter.delete_many(model: config[:two_factor_table], where: [{field: "userId", value: session[:user]["id"]}])
94
+ ctx.context.adapter.create(
95
+ model: config[:two_factor_table],
96
+ data: {
97
+ secret: Crypto.symmetric_encrypt(key: ctx.context.secret, data: secret),
98
+ backupCodes: backup[:stored],
99
+ userId: session[:user]["id"]
100
+ }
101
+ )
102
+
103
+ ctx.json({
104
+ totpURI: two_factor_totp_uri(secret, issuer: body[:issuer] || config[:issuer] || ctx.context.app_name, account: session[:user]["email"], options: config[:totp_options]),
105
+ backupCodes: backup[:codes]
106
+ })
107
+ end
108
+ end
109
+
110
+ 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)
113
+ body = normalize_hash(ctx.body)
114
+ two_factor_check_password!(ctx, session[:user]["id"], body[:password])
115
+
116
+ 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"]}])
118
+ new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
119
+ Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
120
+ ctx.context.internal_adapter.delete_session(session[:session]["token"])
121
+
122
+ trust_cookie = ctx.context.create_auth_cookie(TRUST_DEVICE_COOKIE_NAME, max_age: config[:trust_device_max_age])
123
+ trust_value = ctx.get_signed_cookie(trust_cookie.name, ctx.context.secret)
124
+ if trust_value
125
+ _token, identifier = trust_value.split("!", 2)
126
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier) if identifier
127
+ Cookies.expire_cookie(ctx, trust_cookie)
128
+ end
129
+ ctx.json({status: true})
130
+ end
131
+ end
132
+
133
+ def two_factor_generate_totp_endpoint(config)
134
+ Endpoint.new(path: "/totp/generate", method: "POST") do |ctx|
135
+ two_factor_totp_enabled!(config)
136
+ body = normalize_hash(ctx.body)
137
+ ctx.json({code: two_factor_totp(body[:secret], options: config[:totp_options])})
138
+ end
139
+ end
140
+
141
+ def two_factor_get_totp_uri_endpoint(config)
142
+ Endpoint.new(path: "/two-factor/get-totp-uri", method: "POST") do |ctx|
143
+ 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])
146
+ record = two_factor_record(ctx, config, session[:user]["id"])
147
+ raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record
148
+
149
+ secret = Crypto.symmetric_decrypt(key: ctx.context.secret, data: record["secret"])
150
+ ctx.json({totpURI: two_factor_totp_uri(secret, issuer: config[:issuer] || ctx.context.app_name, account: session[:user]["email"], options: config[:totp_options])})
151
+ end
152
+ end
153
+
154
+ def two_factor_verify_totp_endpoint(config)
155
+ Endpoint.new(path: "/two-factor/verify-totp", method: "POST") do |ctx|
156
+ two_factor_totp_enabled!(config)
157
+ body = normalize_hash(ctx.body)
158
+ data = two_factor_verification_context(ctx, config)
159
+ record = two_factor_record(ctx, config, data[:session][:user]["id"])
160
+ raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record
161
+
162
+ secret = Crypto.symmetric_decrypt(key: ctx.context.secret, data: record["secret"])
163
+ raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"]) unless two_factor_totp_valid?(secret, body[:code], options: config[:totp_options])
164
+
165
+ if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
166
+ updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true)
167
+ new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
168
+ ctx.context.internal_adapter.delete_session(data[:session][:session]["token"])
169
+ Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
170
+ end
171
+ data[:valid].call
172
+ end
173
+ end
174
+
175
+ def two_factor_send_otp_endpoint(config)
176
+ Endpoint.new(path: "/two-factor/send-otp", method: "POST") do |ctx|
177
+ otp_config = config[:otp_options]
178
+ sender = otp_config[:send_otp]
179
+ unless sender.respond_to?(:call)
180
+ raise APIError.new("BAD_REQUEST", message: "otp isn't configured")
181
+ end
182
+
183
+ data = two_factor_verification_context(ctx, config)
184
+ code = two_factor_random_digits((otp_config[:digits] || 6).to_i)
185
+ stored = two_factor_store_otp_value(ctx, code, otp_config)
186
+ ctx.context.internal_adapter.create_verification_value(
187
+ identifier: "2fa-otp-#{data[:key]}",
188
+ value: "#{stored}:0",
189
+ expiresAt: Time.now + ((otp_config[:period] || 3).to_i * 60)
190
+ )
191
+ sender.call({user: data[:session][:user], otp: code}, ctx)
192
+ ctx.json({status: true})
193
+ end
194
+ end
195
+
196
+ def two_factor_verify_otp_endpoint(config)
197
+ Endpoint.new(path: "/two-factor/verify-otp", method: "POST") do |ctx|
198
+ body = normalize_hash(ctx.body)
199
+ data = two_factor_verification_context(ctx, config)
200
+ verification = ctx.context.internal_adapter.find_verification_value("2fa-otp-#{data[:key]}")
201
+ stored, counter = verification&.fetch("value", nil).to_s.split(":", 2)
202
+ if !verification || verification["expiresAt"] < Time.now
203
+ ctx.context.internal_adapter.delete_verification_value(verification["id"]) if verification
204
+ raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["OTP_HAS_EXPIRED"])
205
+ end
206
+
207
+ allowed = (config[:otp_options][:allowed_attempts] || 5).to_i
208
+ if counter.to_i >= allowed
209
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
210
+ raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE"])
211
+ end
212
+
213
+ unless two_factor_otp_matches?(ctx, stored, body[:code].to_s, config[:otp_options])
214
+ ctx.context.internal_adapter.update_verification_value(verification["id"], value: "#{stored}:#{counter.to_i + 1}")
215
+ raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"])
216
+ end
217
+
218
+ if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
219
+ updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true)
220
+ new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
221
+ ctx.context.internal_adapter.delete_session(data[:session][:session]["token"])
222
+ Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
223
+ next ctx.json({token: new_session["token"], user: Schema.parse_output(ctx.context.options, "user", updated_user)})
224
+ end
225
+
226
+ data[:valid].call
227
+ end
228
+ end
229
+
230
+ def two_factor_verify_backup_code_endpoint(config)
231
+ Endpoint.new(path: "/two-factor/verify-backup-code", method: "POST") do |ctx|
232
+ body = normalize_hash(ctx.body)
233
+ data = two_factor_verification_context(ctx, config)
234
+ record = two_factor_record(ctx, config, data[:session][:user]["id"])
235
+ raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record
236
+
237
+ codes = two_factor_read_backup_codes(ctx.context.secret, record["backupCodes"], config[:backup_code_options])
238
+ unless codes.include?(body[:code].to_s)
239
+ raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_BACKUP_CODE"])
240
+ end
241
+
242
+ remaining = codes.reject { |code| code == body[:code].to_s }
243
+ stored = two_factor_store_backup_codes(ctx.context.secret, remaining, config[:backup_code_options])
244
+ updated = ctx.context.adapter.update(
245
+ model: config[:two_factor_table],
246
+ where: [{field: "id", value: record["id"]}, {field: "backupCodes", value: record["backupCodes"]}],
247
+ update: {backupCodes: stored}
248
+ )
249
+ raise APIError.new("CONFLICT", message: "Failed to verify backup code. Please try again.") unless updated
250
+
251
+ body[:disable_session] ? ctx.json({token: data[:session][:session]&.fetch("token", nil), user: Schema.parse_output(ctx.context.options, "user", data[:session][:user])}) : data[:valid].call
252
+ end
253
+ end
254
+
255
+ 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)
258
+ raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless session[:user]["twoFactorEnabled"]
259
+
260
+ two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password])
261
+ record = two_factor_record(ctx, config, session[:user]["id"])
262
+ raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless record
263
+
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]})
266
+ ctx.json({status: true, backupCodes: backup[:codes]})
267
+ end
268
+ end
269
+
270
+ def two_factor_view_backup_codes_endpoint(config)
271
+ Endpoint.new(method: "POST") do |ctx|
272
+ body = normalize_hash(ctx.body)
273
+ record = two_factor_record(ctx, config, body[:user_id])
274
+ raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record
275
+
276
+ ctx.json({status: true, backupCodes: two_factor_read_backup_codes(ctx.context.secret, record["backupCodes"], config[:backup_code_options])})
277
+ end
278
+ end
279
+
280
+ def two_factor_schema(custom_schema = nil)
281
+ base = {
282
+ user: {
283
+ fields: {
284
+ twoFactorEnabled: {type: "boolean", required: false, default_value: false, returned: true}
285
+ }
286
+ },
287
+ twoFactor: {
288
+ fields: {
289
+ secret: {type: "string", required: true, returned: false, index: true},
290
+ backupCodes: {type: "string", required: true, returned: false},
291
+ userId: {type: "string", required: true, returned: false, index: true, references: {model: "user", field: "id"}}
292
+ }
293
+ }
294
+ }
295
+ deep_merge_hashes(base, normalize_hash(custom_schema || {}))
296
+ end
297
+
298
+ def two_factor_after_sign_in(ctx, config)
299
+ data = ctx.context.new_session
300
+ return unless data && data[:user] && data[:session]
301
+ return unless data[:user]["twoFactorEnabled"]
302
+ return if two_factor_trusted_device_valid?(ctx, config, data[:user]["id"])
303
+
304
+ Cookies.delete_session_cookie(ctx, skip_dont_remember_me: true)
305
+ ctx.context.internal_adapter.delete_session(data[:session]["token"])
306
+ cookie = ctx.context.create_auth_cookie(TWO_FACTOR_COOKIE_NAME, max_age: config[:two_factor_cookie_max_age])
307
+ identifier = "2fa-#{Crypto.random_string(20)}"
308
+ ctx.context.internal_adapter.create_verification_value(
309
+ identifier: identifier,
310
+ value: data[:user]["id"],
311
+ expiresAt: Time.now + config[:two_factor_cookie_max_age].to_i
312
+ )
313
+ ctx.set_signed_cookie(cookie.name, identifier, ctx.context.secret, cookie.attributes)
314
+ ctx.json({twoFactorRedirect: true})
315
+ end
316
+
317
+ def two_factor_verification_context(ctx, config)
318
+ session = Routes.current_session(ctx, allow_nil: true)
319
+ if session
320
+ key = "#{session[:user]["id"]}!#{session[:session]["id"]}"
321
+ return {session: session, key: key, valid: -> { ctx.json({token: session[:session]["token"], user: Schema.parse_output(ctx.context.options, "user", session[:user])}) }}
322
+ end
323
+
324
+ cookie = ctx.context.create_auth_cookie(TWO_FACTOR_COOKIE_NAME)
325
+ identifier = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
326
+ raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless identifier
327
+
328
+ verification = ctx.context.internal_adapter.find_verification_value(identifier)
329
+ raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless verification && verification["expiresAt"] > Time.now
330
+
331
+ user = ctx.context.internal_adapter.find_user_by_id(verification["value"])
332
+ raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless user
333
+
334
+ valid = lambda do
335
+ dont_remember_me = Cookies.dont_remember?(ctx)
336
+ new_session = ctx.context.internal_adapter.create_session(user["id"], dont_remember_me)
337
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "failed to create session") unless new_session
338
+
339
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
340
+ Cookies.set_session_cookie(ctx, {session: new_session, user: user}, dont_remember_me)
341
+ Cookies.expire_cookie(ctx, cookie)
342
+ if normalize_hash(ctx.body)[:trust_device]
343
+ two_factor_set_trusted_device(ctx, config, user["id"])
344
+ Cookies.expire_cookie(ctx, ctx.context.auth_cookies[:dont_remember])
345
+ end
346
+ ctx.json({token: new_session["token"], user: Schema.parse_output(ctx.context.options, "user", user)})
347
+ end
348
+
349
+ {session: {session: nil, user: user}, key: identifier, valid: valid}
350
+ end
351
+
352
+ def two_factor_set_trusted_device(ctx, config, user_id)
353
+ max_age = config[:trust_device_max_age].to_i
354
+ identifier = "trust-device-#{Crypto.random_string(32)}"
355
+ token = Crypto.hmac_signature("#{user_id}!#{identifier}", ctx.context.secret, encoding: :base64url)
356
+ ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: user_id, expiresAt: Time.now + max_age)
357
+ cookie = ctx.context.create_auth_cookie(TRUST_DEVICE_COOKIE_NAME, max_age: max_age)
358
+ ctx.set_signed_cookie(cookie.name, "#{token}!#{identifier}", ctx.context.secret, cookie.attributes)
359
+ end
360
+
361
+ def two_factor_trusted_device_valid?(ctx, config, user_id)
362
+ cookie = ctx.context.create_auth_cookie(TRUST_DEVICE_COOKIE_NAME, max_age: config[:trust_device_max_age])
363
+ value = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
364
+ return false unless value
365
+
366
+ token, identifier = value.split("!", 2)
367
+ expected = Crypto.hmac_signature("#{user_id}!#{identifier}", ctx.context.secret, encoding: :base64url)
368
+ verification = identifier && ctx.context.internal_adapter.find_verification_value(identifier)
369
+ if token && identifier && Crypto.constant_time_compare(token, expected) && verification && verification["value"] == user_id && verification["expiresAt"] > Time.now
370
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
371
+ two_factor_set_trusted_device(ctx, config, user_id)
372
+ true
373
+ else
374
+ Cookies.expire_cookie(ctx, cookie)
375
+ false
376
+ end
377
+ end
378
+
379
+ 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}])
381
+ end
382
+
383
+ def two_factor_check_password!(ctx, user_id, password)
384
+ account = ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" }
385
+ unless account && account["password"] && Routes.verify_password_value(ctx, password.to_s, account["password"])
386
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
387
+ end
388
+ end
389
+
390
+ def two_factor_totp_enabled!(config)
391
+ if config[:totp_options][:disable]
392
+ raise APIError.new("BAD_REQUEST", message: "totp isn't configured")
393
+ end
394
+ end
395
+
396
+ def two_factor_generate_secret
397
+ raw = SecureRandom.random_bytes(20)
398
+ base32_encode(raw)
399
+ end
400
+
401
+ def two_factor_totp(secret, options: {})
402
+ interval = Time.now.to_i / (options[:period] || 30).to_i
403
+ two_factor_totp_at(secret, interval, digits: (options[:digits] || 6).to_i)
404
+ end
405
+
406
+ def two_factor_totp_valid?(secret, code, options: {})
407
+ period = (options[:period] || 30).to_i
408
+ interval = Time.now.to_i / period
409
+ (-1..1).any? { |offset| Crypto.constant_time_compare(two_factor_totp_at(secret, interval + offset, digits: (options[:digits] || 6).to_i), code.to_s) }
410
+ end
411
+
412
+ def two_factor_totp_at(secret, counter, digits:)
413
+ key = base32_decode(secret)
414
+ digest = OpenSSL::HMAC.digest("SHA1", key, [counter].pack("Q>"))
415
+ offset = digest.bytes.last & 0x0f
416
+ binary = digest.byteslice(offset, 4).unpack1("N") & 0x7fffffff
417
+ (binary % (10**digits)).to_s.rjust(digits, "0")
418
+ end
419
+
420
+ def two_factor_totp_uri(secret, issuer:, account:, options: {})
421
+ label = "#{issuer}:#{account}"
422
+ params = {secret: secret, issuer: issuer, digits: options[:digits] || 6, period: options[:period] || 30}
423
+ "otpauth://totp/#{URI.encode_www_form_component(label)}?#{URI.encode_www_form(params)}"
424
+ end
425
+
426
+ def two_factor_generate_backup_codes(secret, options)
427
+ codes = if options[:custom_backup_codes_generate].respond_to?(:call)
428
+ options[:custom_backup_codes_generate].call
429
+ else
430
+ amount = (options[:amount] || 10).to_i
431
+ length = (options[:length] || 10).to_i
432
+ Array.new(amount) do
433
+ value = Crypto.random_string(length)
434
+ "#{value[0, 5]}-#{value[5..]}"
435
+ end
436
+ end
437
+ {codes: codes, stored: two_factor_store_backup_codes(secret, codes, options)}
438
+ end
439
+
440
+ def two_factor_store_backup_codes(secret, codes, options)
441
+ data = JSON.generate(codes)
442
+ storage = options[:store_backup_codes]
443
+ if storage == "encrypted"
444
+ Crypto.symmetric_encrypt(key: secret, data: data)
445
+ elsif storage.is_a?(Hash) && storage[:encrypt].respond_to?(:call)
446
+ storage[:encrypt].call(data)
447
+ else
448
+ data
449
+ end
450
+ end
451
+
452
+ def two_factor_read_backup_codes(secret, stored, options)
453
+ storage = options[:store_backup_codes]
454
+ data = if storage == "encrypted"
455
+ Crypto.symmetric_decrypt(key: secret, data: stored)
456
+ elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
457
+ storage[:decrypt].call(stored)
458
+ else
459
+ stored
460
+ end
461
+ JSON.parse(data.to_s)
462
+ rescue JSON::ParserError
463
+ []
464
+ end
465
+
466
+ def two_factor_random_digits(length)
467
+ Array.new(length) { SecureRandom.random_number(10) }.join
468
+ end
469
+
470
+ def two_factor_store_otp_value(ctx, code, options)
471
+ storage = options[:store_otp]
472
+ if storage == "hashed"
473
+ Crypto.sha256(code, encoding: :base64url)
474
+ elsif storage == "encrypted"
475
+ Crypto.symmetric_encrypt(key: ctx.context.secret, data: code)
476
+ elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
477
+ storage[:hash].call(code)
478
+ elsif storage.is_a?(Hash) && storage[:encrypt].respond_to?(:call)
479
+ storage[:encrypt].call(code)
480
+ else
481
+ code
482
+ end
483
+ end
484
+
485
+ def two_factor_otp_matches?(ctx, stored, input, options)
486
+ storage = options[:store_otp]
487
+ expected, actual = if storage == "hashed"
488
+ [stored, Crypto.sha256(input, encoding: :base64url)]
489
+ elsif storage == "encrypted"
490
+ [Crypto.symmetric_decrypt(key: ctx.context.secret, data: stored), input]
491
+ elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
492
+ [stored, storage[:hash].call(input)]
493
+ elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
494
+ [storage[:decrypt].call(stored), input]
495
+ else
496
+ [stored, input]
497
+ end
498
+ expected && actual && Crypto.constant_time_compare(expected.to_s, actual.to_s)
499
+ end
500
+
501
+ BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
502
+
503
+ def base32_encode(bytes)
504
+ bits = bytes.bytes.map { |byte| byte.to_s(2).rjust(8, "0") }.join
505
+ bits.scan(/.{1,5}/).map { |chunk| BASE32_ALPHABET[chunk.ljust(5, "0").to_i(2)] }.join
506
+ end
507
+
508
+ def base32_decode(value)
509
+ clean = value.to_s.upcase.gsub(/[^A-Z2-7]/, "")
510
+ bits = clean.chars.map { |char| BASE32_ALPHABET.index(char).to_i.to_s(2).rjust(5, "0") }.join
511
+ bits.scan(/.{8}/).map { |byte| byte.to_i(2).chr }.join
512
+ end
513
+ end
514
+ end