parse-stack-next 4.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 (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,449 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../two_factor_auth"
5
+ require_relative "../model/phone"
6
+
7
+ module Parse
8
+ module MFA
9
+ # User extension module that adds MFA capabilities to Parse::User.
10
+ #
11
+ # This module integrates with Parse Server's built-in MFA adapter,
12
+ # which stores MFA data in the user's authData.mfa field.
13
+ #
14
+ # == Parse Server Configuration Required
15
+ #
16
+ # Your Parse Server must have MFA enabled in the auth configuration:
17
+ #
18
+ # {
19
+ # auth: {
20
+ # mfa: {
21
+ # enabled: true,
22
+ # options: ["TOTP"],
23
+ # digits: 6,
24
+ # period: 30,
25
+ # algorithm: "SHA1"
26
+ # }
27
+ # }
28
+ # }
29
+ #
30
+ # @see Parse::MFA
31
+ module UserExtension
32
+ extend ActiveSupport::Concern
33
+
34
+ # Class methods added to Parse::User
35
+ module ClassMethods
36
+ # Login a user with username, password, and MFA token.
37
+ #
38
+ # This method handles the Parse Server MFA "additional" policy,
39
+ # which requires both standard credentials AND an MFA token.
40
+ #
41
+ # @param username [String] The username
42
+ # @param password [String] The password
43
+ # @param mfa_token [String] The TOTP code from authenticator app or recovery code
44
+ # @return [User, nil] The logged in user or nil if failed
45
+ # @raise [Parse::MFA::VerificationError] If MFA token is invalid
46
+ # @raise [Parse::MFA::RequiredError] If MFA is required but token not provided
47
+ #
48
+ # @example
49
+ # user = Parse::User.login_with_mfa("john", "password123", "123456")
50
+ def login_with_mfa(username, password, mfa_token)
51
+ raise MFA::RequiredError, "MFA token is required" if mfa_token.blank?
52
+
53
+ response = client.login_with_mfa(username, password, mfa_token)
54
+ return nil unless response.success?
55
+
56
+ Parse::User.build(response.result)
57
+ rescue Parse::Client::ResponseError => e
58
+ if e.message.include?("Invalid MFA token") || e.message.include?("Missing additional authData")
59
+ raise MFA::VerificationError, e.message
60
+ end
61
+ raise
62
+ end
63
+
64
+ # Check if a user requires MFA for login.
65
+ #
66
+ # This queries the user's authData.mfa status using the afterFind hook
67
+ # which returns { status: "enabled" } or { status: "disabled" }.
68
+ #
69
+ # @param username [String] The username to check
70
+ # @return [Boolean] True if MFA is required
71
+ #
72
+ # @example
73
+ # if Parse::User.mfa_required?("john")
74
+ # # Show MFA input field
75
+ # end
76
+ def mfa_required?(username)
77
+ user = where(username: username).first
78
+ return false unless user
79
+
80
+ user.mfa_enabled?
81
+ end
82
+ end
83
+
84
+ # Check if MFA is enabled for this user.
85
+ #
86
+ # @return [Boolean] True if MFA is enabled
87
+ def mfa_enabled?
88
+ return false unless auth_data.is_a?(Hash)
89
+ return false unless auth_data["mfa"].is_a?(Hash)
90
+
91
+ # Parse Server's afterFind returns { status: "enabled" } for enabled MFA
92
+ mfa_data = auth_data["mfa"]
93
+ mfa_data["status"] == "enabled" || mfa_data["secret"].present? || mfa_data["mobile"].present?
94
+ end
95
+
96
+ # Get the MFA status for this user.
97
+ #
98
+ # @return [Symbol] :enabled, :disabled, or :unknown
99
+ def mfa_status
100
+ return :unknown unless auth_data.is_a?(Hash)
101
+ return :disabled unless auth_data["mfa"].is_a?(Hash)
102
+
103
+ mfa_data = auth_data["mfa"]
104
+ if mfa_data["status"]
105
+ mfa_data["status"].to_sym
106
+ elsif mfa_data["secret"].present? || mfa_data["mobile"].present?
107
+ :enabled
108
+ else
109
+ :disabled
110
+ end
111
+ end
112
+
113
+ # Setup TOTP-based MFA for this user.
114
+ #
115
+ # This sends the secret and verification token to Parse Server,
116
+ # which validates the TOTP and stores the secret securely.
117
+ #
118
+ # @param secret [String] Base32-encoded TOTP secret (generate with MFA.generate_secret)
119
+ # @param token [String] Current TOTP code for verification (user enters from app)
120
+ # @return [String] Recovery codes (comma-separated) - SAVE THESE!
121
+ # @raise [Parse::MFA::VerificationError] If token is invalid
122
+ # @raise [Parse::MFA::AlreadyEnabledError] If MFA is already enabled
123
+ # @raise [ArgumentError] If secret or token is blank
124
+ #
125
+ # @example
126
+ # secret = Parse::MFA.generate_secret
127
+ # # Show QR code to user: Parse::MFA.qr_code(secret, user.email)
128
+ # # User scans and enters code from authenticator app
129
+ # recovery = user.setup_mfa!(secret: secret, token: "123456")
130
+ # puts "Save these recovery codes: #{recovery}"
131
+ def setup_mfa!(secret:, token:)
132
+ raise ArgumentError, "Secret is required" if secret.blank?
133
+ raise ArgumentError, "Token is required" if token.blank?
134
+ # Refresh authData from the server before gating on mfa_enabled?
135
+ # so a stale in-memory user does not bypass the local guard. This
136
+ # narrows the race window from "any time the user object is alive"
137
+ # to "one round-trip" — it does not eliminate TOCTOU. Full
138
+ # elimination requires the Parse Server MFA adapter to reject
139
+ # re-setup when authData.mfa.status == "enabled".
140
+ fetch if id.present?
141
+ raise MFA::AlreadyEnabledError if mfa_enabled?
142
+
143
+ # Validate secret length (Parse Server requires minimum 20 chars)
144
+ if secret.length < 20
145
+ raise ArgumentError, "Secret must be at least 20 characters (got #{secret.length})"
146
+ end
147
+
148
+ auth_data_payload = {
149
+ mfa: {
150
+ secret: secret,
151
+ token: token,
152
+ },
153
+ }
154
+
155
+ response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })
156
+
157
+ if response.error?
158
+ if response.result.to_s.include?("Invalid MFA")
159
+ raise MFA::VerificationError, response.result.to_s
160
+ end
161
+ raise Parse::Client::ResponseError, response
162
+ end
163
+
164
+ # Parse Server returns recovery codes in the response
165
+ recovery = response.result["recovery"] || response.result["authDataResponse"]&.dig("mfa", "recovery")
166
+
167
+ # Refresh auth_data
168
+ fetch
169
+
170
+ recovery
171
+ end
172
+
173
+ # Setup SMS-based MFA for this user.
174
+ #
175
+ # This initiates SMS MFA setup by registering the mobile number.
176
+ # Parse Server will send an SMS with a verification code.
177
+ #
178
+ # @param mobile [String, Parse::Phone] Phone number in E.164 format (e.g., "+14155551234")
179
+ # @return [Boolean] True if SMS was sent
180
+ # @raise [ArgumentError] If mobile is blank or invalid format
181
+ #
182
+ # @example
183
+ # user.setup_sms_mfa!(mobile: "+14155551234")
184
+ # # User receives SMS, then call confirm_sms_mfa!
185
+ def setup_sms_mfa!(mobile:)
186
+ raise ArgumentError, "Mobile number is required" if mobile.blank?
187
+
188
+ # Use Parse::Phone for validation
189
+ phone = mobile.is_a?(Parse::Phone) ? mobile : Parse::Phone.new(mobile)
190
+ unless phone.valid?
191
+ raise ArgumentError, "Invalid mobile number format. Must be E.164 format: +[country code][number] (e.g., +14155551234)"
192
+ end
193
+
194
+ mobile = phone.to_s # Use normalized E.164 format
195
+
196
+ # Same TOCTOU narrowing as #setup_mfa!: refresh authData before
197
+ # the guard so a stale in-memory user cannot bypass the check.
198
+ # See #setup_mfa! for the residual-risk caveat.
199
+ fetch if id.present?
200
+ raise MFA::AlreadyEnabledError if mfa_enabled?
201
+
202
+ auth_data_payload = {
203
+ mfa: {
204
+ mobile: mobile,
205
+ },
206
+ }
207
+
208
+ response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })
209
+
210
+ if response.error?
211
+ raise Parse::Client::ResponseError, response
212
+ end
213
+
214
+ true
215
+ end
216
+
217
+ # Confirm SMS MFA setup with the received code.
218
+ #
219
+ # @param mobile [String, Parse::Phone] The mobile number that was used in setup (E.164 format)
220
+ # @param token [String] The SMS code received
221
+ # @return [Boolean] True if confirmed successfully
222
+ # @raise [Parse::MFA::VerificationError] If token is invalid or expired
223
+ #
224
+ # @example
225
+ # user.confirm_sms_mfa!(mobile: "+14155551234", token: "123456")
226
+ def confirm_sms_mfa!(mobile:, token:)
227
+ raise ArgumentError, "Mobile number is required" if mobile.blank?
228
+ raise ArgumentError, "Token is required" if token.blank?
229
+
230
+ # Use Parse::Phone for validation
231
+ phone = mobile.is_a?(Parse::Phone) ? mobile : Parse::Phone.new(mobile)
232
+ unless phone.valid?
233
+ raise ArgumentError, "Invalid mobile number format. Must be E.164 format: +[country code][number] (e.g., +14155551234)"
234
+ end
235
+
236
+ mobile = phone.to_s # Use normalized E.164 format
237
+
238
+ auth_data_payload = {
239
+ mfa: {
240
+ mobile: mobile,
241
+ token: token,
242
+ },
243
+ }
244
+
245
+ response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })
246
+
247
+ if response.error?
248
+ if response.result.to_s.include?("Invalid MFA token")
249
+ raise MFA::VerificationError, response.result.to_s
250
+ end
251
+ raise Parse::Client::ResponseError, response
252
+ end
253
+
254
+ # Refresh auth_data
255
+ fetch
256
+
257
+ true
258
+ end
259
+
260
+ # Disable MFA for this user.
261
+ #
262
+ # This requires a valid current MFA token (TOTP or recovery code)
263
+ # to verify the user's identity before disabling MFA.
264
+ #
265
+ # @param current_token [String] Current TOTP code or recovery code
266
+ # @return [Boolean] True if disabled successfully
267
+ # @raise [Parse::MFA::VerificationError] If token is invalid
268
+ # @raise [Parse::MFA::NotEnabledError] If MFA is not enabled
269
+ #
270
+ # @example
271
+ # user.disable_mfa!(current_token: "123456")
272
+ def disable_mfa!(current_token:)
273
+ raise MFA::NotEnabledError, "MFA is not enabled for this user" unless mfa_enabled?
274
+ raise ArgumentError, "Current token is required" if current_token.blank?
275
+
276
+ # To disable, we need to update authData.mfa with the old token for validation
277
+ # and then set it to null
278
+ auth_data_payload = {
279
+ mfa: {
280
+ old: current_token,
281
+ secret: nil, # Setting to nil disables TOTP
282
+ },
283
+ }
284
+
285
+ response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })
286
+
287
+ if response.error?
288
+ if response.result.to_s.include?("Invalid MFA token")
289
+ raise MFA::VerificationError, response.result.to_s
290
+ end
291
+ raise Parse::Client::ResponseError, response
292
+ end
293
+
294
+ # Refresh auth_data
295
+ fetch
296
+
297
+ true
298
+ end
299
+
300
+ # Disable MFA using the configured master key. This bypasses MFA
301
+ # verification entirely, so the caller must prove (out-of-band) that
302
+ # the operator initiating the disable is authorized to do so.
303
+ #
304
+ # The +authorized_by:+ keyword is required and must be a
305
+ # {Parse::User} (or {Parse::Pointer} to a User) representing the
306
+ # operator performing the override. The caller is responsible for
307
+ # verifying that operator's privileges (e.g. via a role check). An
308
+ # optional +admin_role:+ argument lets this method enforce a role
309
+ # membership check on the operator using the existing role-hierarchy
310
+ # support; when given, the operator must belong to the role (or any
311
+ # of its child roles) or +ForbiddenError+ is raised.
312
+ #
313
+ # @param authorized_by [Parse::User, Parse::Pointer] the operator
314
+ # performing the override. Required.
315
+ # @param admin_role [Parse::Role, String, nil] optional role (or role
316
+ # name) that +authorized_by+ must belong to.
317
+ # @return [Boolean] True if disabled successfully.
318
+ # @raise [ArgumentError] when +authorized_by:+ is missing or not a User.
319
+ # @raise [Parse::MFA::ForbiddenError] when +admin_role+ is supplied
320
+ # and the operator is not a member.
321
+ #
322
+ # @example Caller-verified authorization
323
+ # user.disable_mfa_master_key!(authorized_by: current_admin)
324
+ #
325
+ # @example Library-enforced role check
326
+ # user.disable_mfa_master_key!(authorized_by: current_admin,
327
+ # admin_role: "Admin")
328
+ def disable_mfa_master_key!(authorized_by:, admin_role: nil)
329
+ operator = authorized_by
330
+ unless operator.is_a?(Parse::User) ||
331
+ (operator.is_a?(Parse::Pointer) && operator.parse_class == Parse::User.parse_class)
332
+ raise ArgumentError,
333
+ "disable_mfa_master_key! requires authorized_by: to be a Parse::User " \
334
+ "or Parse::Pointer to a User (got #{operator.class})"
335
+ end
336
+ if operator.respond_to?(:id) && operator.id.blank?
337
+ raise ArgumentError, "authorized_by: User must be persisted (have an objectId)"
338
+ end
339
+
340
+ if admin_role
341
+ role = admin_role.is_a?(Parse::Role) ? admin_role : Parse::Role.find_by_name(admin_role.to_s)
342
+ if role.nil?
343
+ raise MFA::ForbiddenError,
344
+ "authorized_by user is not authorized: admin role " \
345
+ "#{admin_role.inspect} not found"
346
+ end
347
+ operator_id = operator.id
348
+ authorized = role.all_users.any? { |u| u.id == operator_id }
349
+ unless authorized
350
+ raise MFA::ForbiddenError,
351
+ "authorized_by user #{operator_id} is not a member of " \
352
+ "role #{role.name.inspect}"
353
+ end
354
+ end
355
+
356
+ auth_data_payload = { mfa: nil }
357
+ response = client.update_user(id, { authData: auth_data_payload }, opts: { use_master_key: true })
358
+
359
+ if response.error?
360
+ raise Parse::Client::ResponseError, response
361
+ end
362
+
363
+ # Refresh auth_data
364
+ fetch
365
+
366
+ true
367
+ end
368
+
369
+ # @deprecated Use {#disable_mfa_master_key!} with an explicit
370
+ # +authorized_by:+ argument. The old name had no authorization gate
371
+ # and acted as a one-call IDOR primitive when invoked on an
372
+ # attacker-controlled user instance.
373
+ def disable_mfa_admin!(*args, **kwargs)
374
+ warn "[DEPRECATION] `disable_mfa_admin!` is deprecated; use " \
375
+ "`disable_mfa_master_key!(authorized_by: <admin user>)`."
376
+ disable_mfa_master_key!(*args, **kwargs)
377
+ end
378
+
379
+ # Login this user instance with password and MFA token.
380
+ #
381
+ # @param password [String] The password
382
+ # @param mfa_token [String] The TOTP code or recovery code
383
+ # @return [Boolean] True if login successful
384
+ # @raise [Parse::MFA::RequiredError] If MFA required but not provided
385
+ # @raise [Parse::MFA::VerificationError] If MFA token is invalid
386
+ #
387
+ # @example
388
+ # user = Parse::User.first
389
+ # user.login_with_mfa!("password123", "123456")
390
+ def login_with_mfa!(password, mfa_token = nil)
391
+ response = client.login_with_mfa(username.to_s, password.to_s, mfa_token)
392
+ apply_attributes!(response.result)
393
+ session_token.present?
394
+ rescue Parse::Client::ResponseError => e
395
+ if e.message.include?("Missing additional authData")
396
+ raise MFA::RequiredError, "MFA token is required for this account"
397
+ elsif e.message.include?("Invalid MFA token")
398
+ raise MFA::VerificationError, e.message
399
+ end
400
+ raise
401
+ end
402
+
403
+ # Generate a provisioning URI for this user.
404
+ #
405
+ # Use this to create a QR code for the user to scan with their
406
+ # authenticator app.
407
+ #
408
+ # @param secret [String] The TOTP secret
409
+ # @param issuer [String] Optional custom issuer name
410
+ # @return [String] otpauth:// URI
411
+ #
412
+ # @example
413
+ # secret = Parse::MFA.generate_secret
414
+ # uri = user.mfa_provisioning_uri(secret, issuer: "MyApp")
415
+ def mfa_provisioning_uri(secret, issuer: nil)
416
+ account_name = email.presence || username.presence || id
417
+ MFA.provisioning_uri(secret, account_name, issuer: issuer)
418
+ end
419
+
420
+ # Generate a QR code for MFA setup.
421
+ #
422
+ # @param secret [String] The TOTP secret
423
+ # @param issuer [String] Optional custom issuer name
424
+ # @param format [Symbol] Output format (:svg, :png, :ascii)
425
+ # @return [String] QR code in specified format
426
+ #
427
+ # @example
428
+ # secret = Parse::MFA.generate_secret
429
+ # qr_svg = user.mfa_qr_code(secret, issuer: "MyApp")
430
+ # # Render in HTML: <%= raw qr_svg %>
431
+ def mfa_qr_code(secret, issuer: nil, format: :svg)
432
+ account_name = email.presence || username.presence || id
433
+ MFA.qr_code(secret, account_name, issuer: issuer, format: format)
434
+ end
435
+ end
436
+
437
+ # Not enabled error
438
+ class NotEnabledError < Parse::Error
439
+ def initialize(message = "MFA is not enabled for this user")
440
+ super(message)
441
+ end
442
+ end
443
+ end
444
+
445
+ # Reopen User class to include MFA extension
446
+ class User
447
+ include MFA::UserExtension
448
+ end
449
+ end