Skip to content

Commit c1a8b57

Browse files
committed
refactor: organize MFA classes into authentication.mfa package
1 parent 2cf87a2 commit c1a8b57

File tree

9 files changed

+842
-32
lines changed

9 files changed

+842
-32
lines changed

EXAMPLES.md

Lines changed: 346 additions & 12 deletions
Large diffs are not rendered by default.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import androidx.annotation.VisibleForTesting
55
import com.auth0.android.Auth0
66
import com.auth0.android.Auth0Exception
77
import com.auth0.android.NetworkErrorException
8+
import com.auth0.android.authentication.mfa.MfaApiClient
89
import com.auth0.android.dpop.DPoP
910
import com.auth0.android.dpop.DPoPException
1011
import com.auth0.android.dpop.SenderConstraining

auth0/src/main/java/com/auth0/android/authentication/MfaApiClient.kt renamed to auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
package com.auth0.android.authentication
1+
package com.auth0.android.authentication.mfa
22

33
import androidx.annotation.VisibleForTesting
44
import com.auth0.android.Auth0
55
import com.auth0.android.Auth0Exception
6-
import com.auth0.android.authentication.MfaException.*
6+
import com.auth0.android.authentication.ParameterBuilder
7+
import com.auth0.android.authentication.mfa.MfaException.*
78
import com.auth0.android.request.ErrorAdapter
89
import com.auth0.android.request.JsonAdapter
910
import com.auth0.android.request.Request
@@ -26,7 +27,7 @@ import java.io.Reader
2627
* API client for handling Multi-Factor Authentication (MFA) flows.
2728
*
2829
* This client provides methods to handle MFA challenges and enrollments following
29-
* the Auth0 MFA API. It is typically obtained from [AuthenticationAPIClient.mfaClient]
30+
* the Auth0 MFA API. It is typically obtained from [com.auth0.android.authentication.AuthenticationAPIClient.mfaClient]
3031
* after receiving an `mfa_required` error during authentication.
3132
*
3233
* ## Usage
@@ -46,7 +47,7 @@ import java.io.Reader
4647
* }
4748
* ```
4849
*
49-
* @see AuthenticationAPIClient.mfaClient
50+
* @see com.auth0.android.authentication.AuthenticationAPIClient.mfaClient
5051
* @see [MFA API Documentation](https://auth0.com/docs/api/authentication#multi-factor-authentication)
5152
*/
5253
public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor(

auth0/src/main/java/com/auth0/android/authentication/MfaException.kt renamed to auth0/src/main/java/com/auth0/android/authentication/mfa/MfaException.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.auth0.android.authentication
1+
package com.auth0.android.authentication.mfa
22

33
import com.auth0.android.Auth0Exception
44
import com.auth0.android.Auth0Exception.Companion.UNKNOWN_ERROR

auth0/src/main/java/com/auth0/android/authentication/MfaTypes.kt renamed to auth0/src/main/java/com/auth0/android/authentication/mfa/MfaTypes.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.auth0.android.authentication
1+
package com.auth0.android.authentication.mfa
22

33
/**
44
* Represents the type of MFA enrollment to perform.

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

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

33
import com.auth0.android.Auth0
4-
import com.auth0.android.authentication.MfaEnrollmentType
5-
import com.auth0.android.authentication.MfaVerificationType
6-
import com.auth0.android.authentication.MfaException.*
4+
import com.auth0.android.authentication.mfa.MfaApiClient
5+
import com.auth0.android.authentication.mfa.MfaEnrollmentType
6+
import com.auth0.android.authentication.mfa.MfaVerificationType
7+
import com.auth0.android.authentication.mfa.MfaException.*
78
import com.auth0.android.callback.Callback
89
import com.auth0.android.request.internal.ThreadSwitcherShadow
910
import com.auth0.android.result.Authenticator

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.auth0.android.authentication
22

3-
import com.auth0.android.authentication.MfaException.*
3+
import com.auth0.android.authentication.mfa.MfaException
4+
import com.auth0.android.authentication.mfa.MfaException.*
45
import org.hamcrest.MatcherAssert.assertThat
56
import org.hamcrest.Matchers.*
67
import org.junit.Test

auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt

Lines changed: 243 additions & 10 deletions
Large diffs are not rendered by default.

auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1878,6 +1878,245 @@ public class SecureCredentialsManagerTest {
18781878
)
18791879
}
18801880

1881+
// ========== MFA Required During Token Renewal Tests ==========
1882+
1883+
@Test
1884+
public fun shouldFailWithMfaRequiredWhenRenewingExpiredCredentials() {
1885+
// Arrange: Set up local authentication to pass
1886+
Mockito.`when`(localAuthenticationManager.authenticate()).then {
1887+
localAuthenticationManager.resultCallback.onSuccess(true)
1888+
}
1889+
// Arrange: Set up expired credentials that need renewal
1890+
val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS)
1891+
insertTestCredentials(false, true, true, expiresAt, "scope")
1892+
Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request)
1893+
1894+
// Create an AuthenticationException that simulates MFA required response
1895+
val mfaRequiredValues = mapOf(
1896+
"error" to "mfa_required",
1897+
"error_description" to "Multifactor authentication required",
1898+
"mfa_token" to "test-mfa-token-12345",
1899+
"mfa_requirements" to mapOf(
1900+
"challenge" to listOf(
1901+
mapOf("type" to "otp"),
1902+
mapOf("type" to "oob")
1903+
)
1904+
)
1905+
)
1906+
val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403)
1907+
Mockito.`when`(request.execute()).thenThrow(mfaRequiredException)
1908+
1909+
// Act: Try to get credentials, which triggers renewal
1910+
manager.getCredentials(callback)
1911+
1912+
// Assert: Verify the callback receives MFA_REQUIRED exception with payload
1913+
verify(callback).onFailure(exceptionCaptor.capture())
1914+
val exception = exceptionCaptor.firstValue
1915+
MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue()))
1916+
// Note: CredentialsManager uses error.message which is the DEFAULT_MESSAGE from AuthenticationException
1917+
MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate"))
1918+
MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException))
1919+
1920+
// Verify MFA payload is properly passed through
1921+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue()))
1922+
MatcherAssert.assertThat(exception.mfaToken, Is.`is`("test-mfa-token-12345"))
1923+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.notNullValue()))
1924+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, Is.`is`(2))
1925+
}
1926+
1927+
@Test
1928+
public fun shouldFailWithMfaRequiredWithEnrollmentOptionsWhenRenewingCredentials() {
1929+
// Arrange: Set up local authentication to pass
1930+
Mockito.`when`(localAuthenticationManager.authenticate()).then {
1931+
localAuthenticationManager.resultCallback.onSuccess(true)
1932+
}
1933+
// Arrange: Set up expired credentials that need renewal
1934+
val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS)
1935+
insertTestCredentials(false, true, true, expiresAt, "scope")
1936+
Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request)
1937+
1938+
// Create an AuthenticationException with enrollment options (user needs to enroll MFA)
1939+
val mfaRequiredValues = mapOf(
1940+
"error" to "mfa_required",
1941+
"error_description" to "Multifactor authentication required",
1942+
"mfa_token" to "enroll-mfa-token",
1943+
"mfa_requirements" to mapOf(
1944+
"enroll" to listOf(
1945+
mapOf("type" to "otp"),
1946+
mapOf("type" to "sms"),
1947+
mapOf("type" to "push-notification")
1948+
)
1949+
)
1950+
)
1951+
val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403)
1952+
Mockito.`when`(request.execute()).thenThrow(mfaRequiredException)
1953+
1954+
// Act: Try to get credentials
1955+
manager.getCredentials(callback)
1956+
1957+
// Assert: Verify MFA required with enrollment options
1958+
verify(callback).onFailure(exceptionCaptor.capture())
1959+
val exception = exceptionCaptor.firstValue
1960+
// Note: CredentialsManager uses error.message which is the DEFAULT_MESSAGE from AuthenticationException
1961+
MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate"))
1962+
MatcherAssert.assertThat(exception.mfaToken, Is.`is`("enroll-mfa-token"))
1963+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, Is.`is`(Matchers.notNullValue()))
1964+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, Is.`is`(3))
1965+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.nullValue()))
1966+
}
1967+
1968+
@Test
1969+
public fun shouldNotStoreMfaPayloadWhenNonMfaApiErrorOccurs() {
1970+
// Arrange: Set up local authentication to pass
1971+
Mockito.`when`(localAuthenticationManager.authenticate()).then {
1972+
localAuthenticationManager.resultCallback.onSuccess(true)
1973+
}
1974+
// Arrange: Set up expired credentials that need renewal
1975+
val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS)
1976+
insertTestCredentials(false, true, true, expiresAt, "scope")
1977+
Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request)
1978+
1979+
// Create a regular API error (not MFA required)
1980+
val regularApiError = AuthenticationException(
1981+
mapOf(
1982+
"error" to "invalid_grant",
1983+
"error_description" to "Invalid refresh token"
1984+
),
1985+
400
1986+
)
1987+
Mockito.`when`(request.execute()).thenThrow(regularApiError)
1988+
1989+
// Act: Try to get credentials
1990+
manager.getCredentials(callback)
1991+
1992+
// Assert: Verify no MFA payload is present for non-MFA errors
1993+
verify(callback).onFailure(exceptionCaptor.capture())
1994+
val exception = exceptionCaptor.firstValue
1995+
// For non-MFA API errors, message is "An error occurred while processing the request."
1996+
MatcherAssert.assertThat(exception.message, Matchers.containsString("processing the request"))
1997+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.nullValue()))
1998+
MatcherAssert.assertThat(exception.mfaToken, Is.`is`(Matchers.nullValue()))
1999+
}
2000+
2001+
@Test
2002+
@ExperimentalCoroutinesApi
2003+
public fun shouldThrowMfaRequiredExceptionWhenAwaitingExpiredCredentials(): Unit = runTest {
2004+
// Arrange: Set up local authentication to pass
2005+
Mockito.`when`(localAuthenticationManager.authenticate()).then {
2006+
localAuthenticationManager.resultCallback.onSuccess(true)
2007+
}
2008+
// Arrange: Set up expired credentials that need renewal
2009+
val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS)
2010+
insertTestCredentials(false, true, true, expiresAt, "scope")
2011+
Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request)
2012+
2013+
// Create an AuthenticationException that simulates MFA required response
2014+
val mfaRequiredValues = mapOf(
2015+
"error" to "mfa_required",
2016+
"error_description" to "Multifactor authentication required",
2017+
"mfa_token" to "await-mfa-token-12345",
2018+
"mfa_requirements" to mapOf(
2019+
"challenge" to listOf(
2020+
mapOf("type" to "otp")
2021+
)
2022+
)
2023+
)
2024+
val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403)
2025+
Mockito.`when`(request.execute()).thenThrow(mfaRequiredException)
2026+
2027+
// Act & Assert: Verify awaitCredentials throws CredentialsManagerException with MFA payload
2028+
val exception = assertThrows(CredentialsManagerException::class.java) {
2029+
runBlocking { manager.awaitCredentials() }
2030+
}
2031+
MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue()))
2032+
MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException))
2033+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue()))
2034+
MatcherAssert.assertThat(exception.mfaToken, Is.`is`("await-mfa-token-12345"))
2035+
}
2036+
2037+
@Test
2038+
public fun shouldFailWithMfaRequiredContainingBothChallengeAndEnrollOptions() {
2039+
// Arrange: Set up local authentication to pass
2040+
Mockito.`when`(localAuthenticationManager.authenticate()).then {
2041+
localAuthenticationManager.resultCallback.onSuccess(true)
2042+
}
2043+
// Arrange: Set up expired credentials that need renewal
2044+
val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS)
2045+
insertTestCredentials(false, true, true, expiresAt, "scope")
2046+
Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request)
2047+
2048+
// Create MFA required with BOTH challenge (existing factors) AND enroll (can add more factors)
2049+
val mfaRequiredValues = mapOf(
2050+
"error" to "mfa_required",
2051+
"error_description" to "Multifactor authentication required",
2052+
"mfa_token" to "combined-mfa-token",
2053+
"mfa_requirements" to mapOf(
2054+
"challenge" to listOf(
2055+
mapOf("type" to "otp")
2056+
),
2057+
"enroll" to listOf(
2058+
mapOf("type" to "sms"),
2059+
mapOf("type" to "push-notification")
2060+
)
2061+
)
2062+
)
2063+
val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403)
2064+
Mockito.`when`(request.execute()).thenThrow(mfaRequiredException)
2065+
2066+
// Act: Try to get credentials
2067+
manager.getCredentials(callback)
2068+
2069+
// Assert: Verify both challenge and enroll are present in the payload
2070+
verify(callback).onFailure(exceptionCaptor.capture())
2071+
val exception = exceptionCaptor.firstValue
2072+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue()))
2073+
MatcherAssert.assertThat(exception.mfaToken, Is.`is`("combined-mfa-token"))
2074+
2075+
// Verify challenge factors
2076+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.notNullValue()))
2077+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, Is.`is`(1))
2078+
2079+
// Verify enroll factors
2080+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, Is.`is`(Matchers.notNullValue()))
2081+
MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, Is.`is`(2))
2082+
}
2083+
2084+
@Test
2085+
public fun shouldPreserveOriginalAuthenticationExceptionAsCauseForMfaRequired() {
2086+
// Arrange: Set up local authentication to pass
2087+
Mockito.`when`(localAuthenticationManager.authenticate()).then {
2088+
localAuthenticationManager.resultCallback.onSuccess(true)
2089+
}
2090+
// Arrange: Set up expired credentials that need renewal
2091+
val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS)
2092+
insertTestCredentials(false, true, true, expiresAt, "scope")
2093+
Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request)
2094+
2095+
val mfaRequiredValues = mapOf(
2096+
"error" to "mfa_required",
2097+
"error_description" to "MFA is required for this action",
2098+
"mfa_token" to "cause-test-token"
2099+
)
2100+
val originalException = AuthenticationException(mfaRequiredValues, 403)
2101+
Mockito.`when`(request.execute()).thenThrow(originalException)
2102+
2103+
// Act
2104+
manager.getCredentials(callback)
2105+
2106+
// Assert: Verify the original exception is preserved as cause
2107+
verify(callback).onFailure(exceptionCaptor.capture())
2108+
val exception = exceptionCaptor.firstValue
2109+
2110+
// The cause should be the original AuthenticationException
2111+
MatcherAssert.assertThat(exception.cause, Is.`is`(Matchers.notNullValue()))
2112+
MatcherAssert.assertThat(exception.cause, IsInstanceOf.instanceOf(AuthenticationException::class.java))
2113+
2114+
val causeException = exception.cause as AuthenticationException
2115+
MatcherAssert.assertThat(causeException.getCode(), Is.`is`("mfa_required"))
2116+
MatcherAssert.assertThat(causeException.isMultifactorRequired, Is.`is`(true))
2117+
MatcherAssert.assertThat(causeException.getDescription(), Is.`is`("MFA is required for this action"))
2118+
}
2119+
18812120
/**
18822121
* Testing that getCredentials execution from multiple threads via multiple instances of SecureCredentialsManager should trigger only one network request.
18832122
*/

0 commit comments

Comments
 (0)