Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import androidx.annotation.VisibleForTesting
import com.auth0.android.Auth0
import com.auth0.android.Auth0Exception
import com.auth0.android.authentication.ParameterBuilder
import com.auth0.android.authentication.mfa.MfaException.*
import com.auth0.android.authentication.mfa.MfaException.MfaChallengeException
import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException
import com.auth0.android.authentication.mfa.MfaException.MfaListAuthenticatorsException
import com.auth0.android.authentication.mfa.MfaException.MfaVerifyException
import com.auth0.android.request.ErrorAdapter
import com.auth0.android.request.JsonAdapter
import com.auth0.android.request.Request
Expand Down Expand Up @@ -58,19 +61,27 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA

// Specialized factories for MFA-specific errors
private val listAuthenticatorsFactory: RequestFactory<MfaListAuthenticatorsException> by lazy {
RequestFactory(auth0.networkingClient, createListAuthenticatorsErrorAdapter())
RequestFactory(auth0.networkingClient, createListAuthenticatorsErrorAdapter()).apply {
setAuth0ClientInfo(auth0.auth0UserAgent.value)
}
}

private val enrollmentFactory: RequestFactory<MfaEnrollmentException> by lazy {
RequestFactory(auth0.networkingClient, createEnrollmentErrorAdapter())
RequestFactory(auth0.networkingClient, createEnrollmentErrorAdapter()).apply {
setAuth0ClientInfo(auth0.auth0UserAgent.value)
}
}

private val challengeFactory: RequestFactory<MfaChallengeException> by lazy {
RequestFactory(auth0.networkingClient, createChallengeErrorAdapter())
RequestFactory(auth0.networkingClient, createChallengeErrorAdapter()).apply {
setAuth0ClientInfo(auth0.auth0UserAgent.value)
}
}

private val verifyFactory: RequestFactory<MfaVerifyException> by lazy {
RequestFactory(auth0.networkingClient, createVerifyErrorAdapter())
RequestFactory(auth0.networkingClient, createVerifyErrorAdapter()).apply {
setAuth0ClientInfo(auth0.auth0UserAgent.value)
}
}

/**
Expand Down Expand Up @@ -175,7 +186,11 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
*/
public fun enroll(type: MfaEnrollmentType): Request<EnrollmentChallenge, MfaEnrollmentException> {
return when (type) {
is MfaEnrollmentType.Phone -> enrollOob(oobChannel = "sms", phoneNumber = type.phoneNumber)
is MfaEnrollmentType.Phone -> enrollOob(
oobChannel = "sms",
phoneNumber = type.phoneNumber
)

is MfaEnrollmentType.Email -> enrollOob(oobChannel = "email", email = type.email)
is MfaEnrollmentType.Otp -> enrollOtpInternal()
is MfaEnrollmentType.Push -> enrollOob(oobChannel = "auth0")
Expand Down Expand Up @@ -228,7 +243,6 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
}



/**
* Verifies an MFA challenge using the specified verification type.
*
Expand Down Expand Up @@ -290,7 +304,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
return object : JsonAdapter<List<Authenticator>> {
override fun fromJson(reader: Reader, metadata: Map<String, Any>): List<Authenticator> {
val allAuthenticators = baseAdapter.fromJson(reader, metadata)

return allAuthenticators.filter { authenticator ->
matchesFactorType(authenticator, factorsAllowed)
}
Expand All @@ -313,9 +327,12 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
* @param factorsAllowed List of allowed factor types
* @return true if the authenticator matches any allowed factor type
*/
private fun matchesFactorType(authenticator: Authenticator, factorsAllowed: List<String>): Boolean {
private fun matchesFactorType(
authenticator: Authenticator,
factorsAllowed: List<String>
): Boolean {
val effectiveType = getEffectiveType(authenticator)

return factorsAllowed.any { factor ->
val normalizedFactor = factor.lowercase(java.util.Locale.ROOT)
when (normalizedFactor) {
Expand All @@ -325,7 +342,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
"oob" -> authenticator.authenticatorType == "oob" || authenticator.type == "oob"
"recovery-code" -> effectiveType == "recovery-code"
"push-notification" -> effectiveType == "push-notification"
else -> effectiveType == normalizedFactor ||
else -> effectiveType == normalizedFactor ||
authenticator.authenticatorType?.lowercase(java.util.Locale.ROOT) == normalizedFactor ||
authenticator.type.lowercase(java.util.Locale.ROOT) == normalizedFactor
}
Expand Down Expand Up @@ -370,7 +387,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
.addHeader(HEADER_AUTHORIZATION, "Bearer $mfaToken")
.addParameter(AUTHENTICATOR_TYPES_KEY, listOf("oob"))
.addParameter(OOB_CHANNELS_KEY, listOf(oobChannel))

if (phoneNumber != null) {
request.addParameter(PHONE_NUMBER_KEY, phoneNumber)
}
Expand Down Expand Up @@ -411,7 +428,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
.setGrantType(GRANT_TYPE_MFA_OOB)
.set(MFA_TOKEN_KEY, mfaToken)
.set(OUT_OF_BAND_CODE_KEY, oobCode)

if (bindingCode != null) {
parametersBuilder.set(BINDING_CODE_KEY, bindingCode)
}
Expand Down Expand Up @@ -465,7 +482,6 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
}



/**
* Creates error adapter for getAuthenticators() operations.
*/
Expand Down Expand Up @@ -643,6 +659,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
private const val RECOVERY_CODE_KEY = "recovery_code"
private const val GRANT_TYPE_MFA_OTP = "http://auth0.com/oauth/grant-type/mfa-otp"
private const val GRANT_TYPE_MFA_OOB = "http://auth0.com/oauth/grant-type/mfa-oob"
private const val GRANT_TYPE_MFA_RECOVERY_CODE = "http://auth0.com/oauth/grant-type/mfa-recovery-code"
private const val GRANT_TYPE_MFA_RECOVERY_CODE =
"http://auth0.com/oauth/grant-type/mfa-recovery-code"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,10 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting
val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build()

val listAdapter = object : JsonAdapter<List<AuthenticationMethod>> {
override fun fromJson(reader: Reader, metadata: Map<String, Any>): List<AuthenticationMethod> {
override fun fromJson(
reader: Reader,
metadata: Map<String, Any>
): List<AuthenticationMethod> {
val container = gson.fromJson(reader, AuthenticationMethods::class.java)
return container.authenticationMethods
}
Expand Down Expand Up @@ -848,5 +851,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting
}
}
}

init {
factory.setAuth0ClientInfo(auth0.auth0UserAgent.value)
}
Comment on lines +855 to +857
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The factory is now configured with setAuth0ClientInfo(...), but the test suite doesn’t currently assert that Auth0-Client is actually sent on the wire for My Account requests. Consider adding a request-level assertion in MyAccountAPIClientTest (e.g., for passkeyEnrollmentChallenge) that the recorded request includes the Auth0-Client header with auth0.auth0UserAgent.value to prevent regressions.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

}

Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ package com.auth0.android.authentication
import com.auth0.android.Auth0
import com.auth0.android.authentication.mfa.MfaApiClient
import com.auth0.android.authentication.mfa.MfaEnrollmentType
import com.auth0.android.authentication.mfa.MfaException.MfaChallengeException
import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException
import com.auth0.android.authentication.mfa.MfaException.MfaListAuthenticatorsException
import com.auth0.android.authentication.mfa.MfaException.MfaVerifyException
import com.auth0.android.authentication.mfa.MfaVerificationType
import com.auth0.android.authentication.mfa.MfaException.*
import com.auth0.android.request.internal.ThreadSwitcherShadow
import com.auth0.android.result.Authenticator
import com.auth0.android.result.Challenge
import com.auth0.android.result.Credentials
import com.auth0.android.result.EnrollmentChallenge
import com.auth0.android.result.MfaEnrollmentChallenge
import com.auth0.android.result.TotpEnrollmentChallenge
import com.auth0.android.util.CallbackMatcher
import com.auth0.android.util.MockCallback
import com.auth0.android.util.SSLTestUtils
import com.google.gson.Gson
Expand All @@ -24,7 +25,12 @@ import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.*
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.instanceOf
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.notNullValue
import org.hamcrest.Matchers.nullValue
import org.junit.After
import org.junit.Assert.assertThrows
import org.junit.Before
Expand Down Expand Up @@ -69,7 +75,11 @@ public class MfaApiClientTest {
)
}

private fun enqueueErrorResponse(error: String, description: String, statusCode: Int = 400): Unit {
private fun enqueueErrorResponse(
error: String,
description: String,
statusCode: Int = 400
): Unit {
val json = """{"error": "$error", "error_description": "$description"}"""
enqueueMockResponse(json, statusCode)
}
Expand All @@ -87,6 +97,51 @@ public class MfaApiClientTest {
}


@Test
public fun shouldIncludeAuth0ClientHeaderInGetAuthenticators(): Unit = runTest {
val json = """[{"id": "sms|dev_123", "type": "oob", "active": true}]"""
enqueueMockResponse(json)

mfaClient.getAuthenticators(listOf("oob")).await()

val request = mockServer.takeRequest()
assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue()))
}

@Test
public fun shouldIncludeAuth0ClientHeaderInEnroll(): Unit = runTest {
val json = """{"id": "sms|dev_123", "auth_session": "session_abc"}"""
enqueueMockResponse(json)

mfaClient.enroll(MfaEnrollmentType.Phone("+12025550135")).await()

val request = mockServer.takeRequest()
assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue()))
}

@Test
public fun shouldIncludeAuth0ClientHeaderInChallenge(): Unit = runTest {
val json = """{"challenge_type": "oob", "oob_code": "oob_123"}"""
enqueueMockResponse(json)

mfaClient.challenge("sms|dev_123").await()

val request = mockServer.takeRequest()
assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue()))
}

@Test
public fun shouldIncludeAuth0ClientHeaderInVerify(): Unit = runTest {
val json =
"""{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
enqueueMockResponse(json)

mfaClient.verify(MfaVerificationType.Otp("123456")).await()

val request = mockServer.takeRequest()
assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue()))
}

@Test
public fun shouldGetAuthenticatorsSuccess(): Unit = runTest {
val json = """[
Expand Down Expand Up @@ -436,7 +491,8 @@ public class MfaApiClientTest {

@Test
public fun shouldVerifyOtpWithCorrectGrantType(): Unit = runTest {
val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
val json =
"""{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
enqueueMockResponse(json)

mfaClient.verify(MfaVerificationType.Otp("123456")).await()
Expand Down Expand Up @@ -500,21 +556,25 @@ public class MfaApiClientTest {

@Test
public fun shouldVerifyOobWithoutBindingCodeSuccess(): Unit = runTest {
val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
val json =
"""{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
enqueueMockResponse(json)

val credentials = mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123")).await()
val credentials =
mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123")).await()

assertThat(credentials, `is`(notNullValue()))
assertThat(credentials.accessToken, `is`(ACCESS_TOKEN))
}

@Test
public fun shouldVerifyOobWithCorrectParameters(): Unit = runTest {
val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
val json =
"""{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
enqueueMockResponse(json)

mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123", bindingCode = "654321")).await()
mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123", bindingCode = "654321"))
.await()

val request = mockServer.takeRequest()
assertThat(request.path, `is`("/oauth/token"))
Expand All @@ -530,7 +590,8 @@ public class MfaApiClientTest {

@Test
public fun shouldVerifyOobWithoutBindingCodeInRequest(): Unit = runTest {
val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
val json =
"""{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
enqueueMockResponse(json)

mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123")).await()
Expand Down Expand Up @@ -565,7 +626,8 @@ public class MfaApiClientTest {
}"""
enqueueMockResponse(json)

val credentials = mfaClient.verify(MfaVerificationType.RecoveryCode("OLD_RECOVERY_CODE")).await()
val credentials =
mfaClient.verify(MfaVerificationType.RecoveryCode("OLD_RECOVERY_CODE")).await()

assertThat(credentials, `is`(notNullValue()))
assertThat(credentials.accessToken, `is`(ACCESS_TOKEN))
Expand All @@ -574,7 +636,8 @@ public class MfaApiClientTest {

@Test
public fun shouldVerifyRecoveryCodeWithCorrectParameters(): Unit = runTest {
val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
val json =
"""{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
enqueueMockResponse(json)

mfaClient.verify(MfaVerificationType.RecoveryCode("RECOVERY_123")).await()
Expand Down Expand Up @@ -671,7 +734,8 @@ public class MfaApiClientTest {

@Test
public fun shouldVerifyOtpWithCallback(): Unit {
val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
val json =
"""{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
enqueueMockResponse(json)

val callback = MockCallback<Credentials, MfaVerifyException>()
Expand Down Expand Up @@ -763,8 +827,10 @@ public class MfaApiClientTest {
private companion object {
private const val CLIENT_ID = "CLIENT_ID"
private const val MFA_TOKEN = "MFA_TOKEN_123"
private const val ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
private const val ID_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Gfx6VO9tcxwk6xqx9yYzSfebfeakZp5JYIgP_edcw_A"
private const val ACCESS_TOKEN =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
private const val ID_TOKEN =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Gfx6VO9tcxwk6xqx9yYzSfebfeakZp5JYIgP_edcw_A"
private const val REFRESH_TOKEN = "REFRESH_TOKEN"
}
}
Loading