@@ -140,139 +140,6 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
140140 return request
141141 }
142142
143- /* *
144- * Creates a JSON adapter that filters and deduplicates authenticators based on allowed factor types.
145- *
146- * This processing is performed internally by the SDK after receiving the API response.
147- * The client only specifies which factor types are allowed; all filtering and deduplication
148- * logic is handled transparently by the SDK.
149- *
150- * **Filtering:**
151- * Authenticators are filtered by their effective type:
152- * - OOB authenticators: matched by their channel ("sms" or "email")
153- * - Other authenticators: matched by their type ("otp", "recovery-code", etc.)
154- *
155- * **Deduplication:**
156- * Multiple enrollments of the same phone number or email are consolidated:
157- * - Active authenticators are preferred over inactive ones
158- * - Among authenticators with the same status, the most recently created is kept
159- *
160- * @param factorsAllowed List of factor types to include (e.g., ["sms", "email", "otp"])
161- * @return A JsonAdapter that produces a filtered and deduplicated list of authenticators
162- */
163- private fun createFilteringAuthenticatorsAdapter (factorsAllowed : List <String >): JsonAdapter <List <Authenticator >> {
164- val baseAdapter = GsonAdapter .forListOf(Authenticator ::class .java, gson)
165- return object : JsonAdapter <List <Authenticator >> {
166- override fun fromJson (reader : Reader , metadata : Map <String , Any >): List <Authenticator > {
167- val allAuthenticators = baseAdapter.fromJson(reader, metadata)
168-
169- val filtered = allAuthenticators.filter { authenticator ->
170- matchesFactorType(authenticator, factorsAllowed)
171- }
172-
173- return deduplicateAuthenticators(filtered)
174- }
175- }
176- }
177-
178- /* *
179- * Checks if an authenticator matches any of the allowed factor types.
180- *
181- * The matching logic handles various factor type aliases:
182- * - "sms" or "phone": matches OOB authenticators with SMS channel
183- * - "email": matches OOB authenticators with email channel
184- * - "otp" or "totp": matches time-based one-time password authenticators
185- * - "oob": matches any out-of-band authenticator regardless of channel
186- * - "recovery-code": matches recovery code authenticators
187- * - "push-notification": matches push notification authenticators
188- *
189- * @param authenticator The authenticator to check
190- * @param factorsAllowed List of allowed factor types
191- * @return true if the authenticator matches any allowed factor type
192- */
193- private fun matchesFactorType (authenticator : Authenticator , factorsAllowed : List <String >): Boolean {
194- val effectiveType = getEffectiveType(authenticator)
195-
196- return factorsAllowed.any { factor ->
197- val normalizedFactor = factor.lowercase(java.util.Locale .ROOT )
198- when (normalizedFactor) {
199- " sms" , " phone" -> effectiveType == " sms" || effectiveType == " phone"
200- " email" -> effectiveType == " email"
201- " otp" , " totp" -> effectiveType == " otp" || effectiveType == " totp"
202- " oob" -> authenticator.authenticatorType == " oob" || authenticator.type == " oob"
203- " recovery-code" -> effectiveType == " recovery-code"
204- " push-notification" -> effectiveType == " push-notification"
205- else -> effectiveType == normalizedFactor ||
206- authenticator.authenticatorType?.lowercase(java.util.Locale .ROOT ) == normalizedFactor ||
207- authenticator.type.lowercase(java.util.Locale .ROOT ) == normalizedFactor
208- }
209- }
210- }
211-
212- /* *
213- * Resolves the effective type of an authenticator for filtering purposes.
214- *
215- * OOB (out-of-band) authenticators use their channel ("sms" or "email") as the
216- * effective type, since users typically filter by delivery method rather than
217- * the generic "oob" type. Other authenticators use their authenticatorType directly.
218- *
219- * @param authenticator The authenticator to get the type for
220- * @return The effective type string used for filtering
221- */
222- private fun getEffectiveType (authenticator : Authenticator ): String {
223- return when (authenticator.authenticatorType) {
224- " oob" -> authenticator.oobChannel ? : " oob"
225- else -> authenticator.authenticatorType ? : authenticator.type
226- }
227- }
228-
229- /* *
230- * Removes duplicate authenticators to return only the most relevant enrollment per identity.
231- *
232- * Users may have multiple enrollments for the same phone number or email address
233- * (e.g., from re-enrolling after failed attempts). This method consolidates them
234- * to present a clean list:
235- *
236- * **Grouping strategy:**
237- * - SMS/Email (OOB): grouped by channel + name (e.g., all "+1234567890" SMS entries)
238- * - TOTP: each authenticator is unique (different authenticator apps)
239- * - Recovery code: only one per user
240- *
241- * **Selection criteria (in order of priority):**
242- * 1. Active authenticators are preferred over inactive ones
243- * 2. Among same status, the most recently created is selected
244- *
245- * @param authenticators The list of authenticators to deduplicate
246- * @return A deduplicated list with one authenticator per unique identity
247- */
248- private fun deduplicateAuthenticators (authenticators : List <Authenticator >): List <Authenticator > {
249- val grouped = authenticators.groupBy { authenticator ->
250- when (authenticator.authenticatorType) {
251- " oob" -> {
252- val channel = authenticator.oobChannel ? : " unknown"
253- val name = authenticator.name ? : authenticator.id
254- " $channel :$name "
255- }
256- " otp" -> {
257- authenticator.id
258- }
259- " recovery-code" -> {
260- " recovery-code"
261- }
262- else -> {
263- authenticator.id
264- }
265- }
266- }
267-
268- return grouped.values.map { group ->
269- group.sortedWith(
270- compareByDescending<Authenticator > { it.active }
271- .thenByDescending { it.createdAt ? : " " }
272- ).first()
273- }
274- }
275-
276143 /* *
277144 * Enrolls a new MFA factor for the user.
278145 *
@@ -402,6 +269,86 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
402269 }
403270 }
404271
272+ // ========== Private Helper Methods ==========
273+
274+ /* *
275+ * Creates a JSON adapter that filters authenticators based on allowed factor types.
276+ *
277+ * This processing is performed internally by the SDK after receiving the API response.
278+ * The client only specifies which factor types are allowed; all filtering logic is handled
279+ * transparently by the SDK.
280+ *
281+ * **Filtering:**
282+ * Authenticators are filtered by their effective type:
283+ * - OOB authenticators: matched by their channel ("sms" or "email")
284+ * - Other authenticators: matched by their type ("otp", "recovery-code", etc.)
285+ *
286+ * @param factorsAllowed List of factor types to include (e.g., ["sms", "email", "otp"])
287+ * @return A JsonAdapter that produces a filtered list of authenticators
288+ */
289+ private fun createFilteringAuthenticatorsAdapter (factorsAllowed : List <String >): JsonAdapter <List <Authenticator >> {
290+ val baseAdapter = GsonAdapter .forListOf(Authenticator ::class .java, gson)
291+ return object : JsonAdapter <List <Authenticator >> {
292+ override fun fromJson (reader : Reader , metadata : Map <String , Any >): List <Authenticator > {
293+ val allAuthenticators = baseAdapter.fromJson(reader, metadata)
294+
295+ return allAuthenticators.filter { authenticator ->
296+ matchesFactorType(authenticator, factorsAllowed)
297+ }
298+ }
299+ }
300+ }
301+
302+ /* *
303+ * Checks if an authenticator matches any of the allowed factor types.
304+ *
305+ * The matching logic handles various factor type aliases:
306+ * - "sms" or "phone": matches OOB authenticators with SMS channel
307+ * - "email": matches OOB authenticators with email channel
308+ * - "otp" or "totp": matches time-based one-time password authenticators
309+ * - "oob": matches any out-of-band authenticator regardless of channel
310+ * - "recovery-code": matches recovery code authenticators
311+ * - "push-notification": matches push notification authenticators
312+ *
313+ * @param authenticator The authenticator to check
314+ * @param factorsAllowed List of allowed factor types
315+ * @return true if the authenticator matches any allowed factor type
316+ */
317+ private fun matchesFactorType (authenticator : Authenticator , factorsAllowed : List <String >): Boolean {
318+ val effectiveType = getEffectiveType(authenticator)
319+
320+ return factorsAllowed.any { factor ->
321+ val normalizedFactor = factor.lowercase(java.util.Locale .ROOT )
322+ when (normalizedFactor) {
323+ " sms" , " phone" -> effectiveType == " sms" || effectiveType == " phone"
324+ " email" -> effectiveType == " email"
325+ " otp" , " totp" -> effectiveType == " otp" || effectiveType == " totp"
326+ " oob" -> authenticator.authenticatorType == " oob" || authenticator.type == " oob"
327+ " recovery-code" -> effectiveType == " recovery-code"
328+ " push-notification" -> effectiveType == " push-notification"
329+ else -> effectiveType == normalizedFactor ||
330+ authenticator.authenticatorType?.lowercase(java.util.Locale .ROOT ) == normalizedFactor ||
331+ authenticator.type.lowercase(java.util.Locale .ROOT ) == normalizedFactor
332+ }
333+ }
334+ }
335+
336+ /* *
337+ * Resolves the effective type of an authenticator for filtering purposes.
338+ *
339+ * OOB (out-of-band) authenticators use their channel ("sms" or "email") as the
340+ * effective type, since users typically filter by delivery method rather than
341+ * the generic "oob" type. Other authenticators use their authenticatorType directly.
342+ *
343+ * @param authenticator The authenticator to get the type for
344+ * @return The effective type string used for filtering
345+ */
346+ private fun getEffectiveType (authenticator : Authenticator ): String {
347+ return when (authenticator.authenticatorType) {
348+ " oob" -> authenticator.oobChannel ? : " oob"
349+ else -> authenticator.authenticatorType ? : authenticator.type
350+ }
351+ }
405352
406353 /* *
407354 * Helper function for OOB enrollment (SMS, email, push).
0 commit comments