Skip to content

Commit 923967f

Browse files
committed
Reorganize private methods, fix nullable fields, add CredentialsManager tests
1 parent 2b1b511 commit 923967f

3 files changed

Lines changed: 192 additions & 135 deletions

File tree

auth0/src/main/java/com/auth0/android/authentication/MfaApiClient.kt

Lines changed: 80 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -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).

auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ public sealed class EnrollmentChallenge {
3434

3535
public data class MfaEnrollmentChallenge(
3636
@SerializedName("id")
37-
override val id: String?,
37+
override val id: String,
3838
@SerializedName("auth_session")
39-
override val authSession: String?
39+
override val authSession: String
4040
) : EnrollmentChallenge()
4141

4242
/**

auth0/src/test/java/com/auth0/android/authentication/MfaExceptionTest.kt

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package com.auth0.android.authentication
22

33
import com.auth0.android.authentication.MfaException.*
4+
import com.auth0.android.authentication.storage.CredentialsManagerException
5+
import com.auth0.android.result.MfaFactor
6+
import com.auth0.android.result.MfaRequiredErrorPayload
7+
import com.auth0.android.result.MfaRequirements
48
import org.hamcrest.MatcherAssert.assertThat
59
import org.hamcrest.Matchers.*
610
import org.junit.Test
@@ -299,4 +303,110 @@ public class MfaExceptionTest {
299303
assertThat(verifyException.message, containsString("custom_error_code"))
300304
}
301305

306+
// ========== CredentialsManagerException MFA Tests ==========
307+
308+
@Test
309+
public fun shouldCredentialsManagerExceptionHaveNullMfaPayloadByDefault(): Unit {
310+
val exception = CredentialsManagerException.RENEW_FAILED
311+
312+
assertThat(exception.mfaRequiredErrorPayload, `is`(nullValue()))
313+
assertThat(exception.mfaToken, `is`(nullValue()))
314+
}
315+
316+
@Test
317+
public fun shouldCredentialsManagerExceptionMfaRequiredHaveCorrectMessage(): Unit {
318+
val exception = CredentialsManagerException.MFA_REQUIRED
319+
320+
assertThat(exception.message, containsString("Multi-factor authentication is required"))
321+
}
322+
323+
@Test
324+
public fun shouldCredentialsManagerExceptionMfaTokenReturnCorrectValue(): Unit {
325+
// Create an MFA payload with a token
326+
val mfaPayload = MfaRequiredErrorPayload(
327+
error = "mfa_required",
328+
errorDescription = "Multifactor authentication required",
329+
mfaToken = "test_mfa_token_123",
330+
mfaRequirements = null
331+
)
332+
333+
// Use reflection to create exception with payload since constructor is internal
334+
val exceptionClass = CredentialsManagerException::class.java
335+
val constructor = exceptionClass.getDeclaredConstructor(
336+
CredentialsManagerException.Code::class.java,
337+
String::class.java,
338+
Throwable::class.java,
339+
MfaRequiredErrorPayload::class.java
340+
)
341+
constructor.isAccessible = true
342+
343+
val codeClass = Class.forName("com.auth0.android.authentication.storage.CredentialsManagerException\$Code")
344+
val mfaRequiredCode = codeClass.getDeclaredField("MFA_REQUIRED").get(null)
345+
346+
val exception = constructor.newInstance(
347+
mfaRequiredCode,
348+
"MFA required",
349+
null,
350+
mfaPayload
351+
) as CredentialsManagerException
352+
353+
assertThat(exception.mfaRequiredErrorPayload, `is`(notNullValue()))
354+
assertThat(exception.mfaToken, `is`("test_mfa_token_123"))
355+
assertThat(exception.mfaRequiredErrorPayload?.mfaToken, `is`("test_mfa_token_123"))
356+
}
357+
358+
@Test
359+
public fun shouldCredentialsManagerExceptionMfaPayloadContainRequirements(): Unit {
360+
// Create MFA requirements with challenge
361+
val challengeFactors = listOf(MfaFactor(type = "otp"), MfaFactor(type = "sms"))
362+
val requirements = MfaRequirements(challenge = challengeFactors, enroll = null)
363+
val mfaPayload = MfaRequiredErrorPayload(
364+
error = "mfa_required",
365+
errorDescription = "Multifactor authentication required",
366+
mfaToken = "token_with_requirements",
367+
mfaRequirements = requirements
368+
)
369+
370+
// Use reflection to create exception with payload
371+
val exceptionClass = CredentialsManagerException::class.java
372+
val constructor = exceptionClass.getDeclaredConstructor(
373+
CredentialsManagerException.Code::class.java,
374+
String::class.java,
375+
Throwable::class.java,
376+
MfaRequiredErrorPayload::class.java
377+
)
378+
constructor.isAccessible = true
379+
380+
val codeClass = Class.forName("com.auth0.android.authentication.storage.CredentialsManagerException\$Code")
381+
val mfaRequiredCode = codeClass.getDeclaredField("MFA_REQUIRED").get(null)
382+
383+
val exception = constructor.newInstance(
384+
mfaRequiredCode,
385+
"MFA required",
386+
null,
387+
mfaPayload
388+
) as CredentialsManagerException
389+
390+
assertThat(exception.mfaRequiredErrorPayload, `is`(notNullValue()))
391+
assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements, `is`(notNullValue()))
392+
assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.map { it.type }, `is`(listOf("otp", "sms")))
393+
}
394+
395+
@Test
396+
public fun shouldCredentialsManagerExceptionEqualityIgnoreMfaPayload(): Unit {
397+
// Two MFA_REQUIRED exceptions should be equal regardless of payload
398+
val exception1 = CredentialsManagerException.MFA_REQUIRED
399+
val exception2 = CredentialsManagerException.MFA_REQUIRED
400+
401+
assertThat(exception1, `is`(exception2))
402+
assertThat(exception1.hashCode(), `is`(exception2.hashCode()))
403+
}
404+
405+
@Test
406+
public fun shouldCredentialsManagerExceptionStaticInstancesBeDistinct(): Unit {
407+
assertThat(CredentialsManagerException.MFA_REQUIRED, `is`(not(CredentialsManagerException.RENEW_FAILED)))
408+
assertThat(CredentialsManagerException.MFA_REQUIRED, `is`(not(CredentialsManagerException.NO_CREDENTIALS)))
409+
assertThat(CredentialsManagerException.MFA_REQUIRED, `is`(not(CredentialsManagerException.API_ERROR)))
410+
}
411+
302412
}

0 commit comments

Comments
 (0)