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
26 changes: 26 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,32 @@ MfaApiClient mfaClient = authentication.mfaClient(mfaToken);
```
</details>

##### Using DPoP with MFA

If the originating `AuthenticationAPIClient` has [DPoP](#dpop) enabled, the resulting `mfaClient` inherits it automatically, and the final `verify()` call exchanging credentials at `/oauth/token` will carry a DPoP proof:

```kotlin
val authentication = AuthenticationAPIClient(account).useDPoP(context)
val mfaClient = authentication.mfaClient(mfaToken) // DPoP inherited
```

Alternatively, if you are using the `MfaApiClient` on its own, enable DPoP directly on it:

```kotlin
val mfaClient = MfaApiClient(account, mfaToken).useDPoP(context)
```

<details>
<summary>Using Java</summary>

```java
MfaApiClient mfaClient = new MfaApiClient(account, mfaToken).useDPoP(context);
```
</details>

> [!NOTE]
> The proof is only attached to the token exchange performed by `verify()`. The `getAuthenticators()`, `enroll()`, and `challenge()` calls authenticate with the MFA token as a bearer credential and do not carry a DPoP proof.

#### Getting Available Authenticators

Retrieve the list of authenticators that the user has enrolled and are allowed for this authentication flow. The `factorsAllowed` parameter filters the authenticators based on the allowed factor types from the MFA requirements.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
* ```
*
* @param mfaToken The token received in the 'mfa_required' error from a login attempt.
* @return A new [MfaApiClient] instance configured for the transaction.
* @return A new [MfaApiClient] instance configured for the transaction. If this client has
* DPoP enabled via [useDPoP], the returned MFA client inherits that configuration.
*/
public fun mfaClient(mfaToken: String): MfaApiClient {
return MfaApiClient(this.auth0, mfaToken)
return MfaApiClient(this.auth0, mfaToken, gson, this.dPoP)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.auth0.android.authentication.mfa

import android.content.Context
import androidx.annotation.VisibleForTesting
import com.auth0.android.Auth0
import com.auth0.android.Auth0Exception
Expand All @@ -8,6 +9,9 @@ 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.dpop.DPoP
import com.auth0.android.dpop.DPoPException
import com.auth0.android.dpop.SenderConstraining
import com.auth0.android.request.ErrorAdapter
import com.auth0.android.request.JsonAdapter
import com.auth0.android.request.Request
Expand Down Expand Up @@ -56,8 +60,19 @@ import java.io.Reader
public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor(
private val auth0: Auth0,
private val mfaToken: String,
private val gson: Gson
) {
private val gson: Gson,
private var dPoP: DPoP? = null
) : SenderConstraining<MfaApiClient> {

/**
* Enable DPoP for this client. When enabled, the MFA verification request to
* `/oauth/token` will carry a DPoP proof, binding the issued tokens to a key pair
* held in the Android KeyStore.
*/
public override fun useDPoP(context: Context): MfaApiClient {
dPoP = DPoP(context)
return this
}

// Specialized factories for MFA-specific errors
private val listAuthenticatorsFactory: RequestFactory<MfaListAuthenticatorsException> by lazy {
Expand Down Expand Up @@ -477,7 +492,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
Credentials::class.java, gson
)

return verifyFactory.post(url.toString(), credentialsAdapter)
return verifyFactory.post(url.toString(), credentialsAdapter, dPoP)
.addParameters(parameters)
}

Expand Down Expand Up @@ -621,14 +636,20 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
}

override fun fromException(cause: Throwable): MfaVerifyException {
return if (isNetworkError(cause)) {
MfaVerifyException(
return when {
isNetworkError(cause) -> MfaVerifyException(
code = "network_error",
description = "Failed to execute the network request",
cause = cause
)
} else {
MfaVerifyException(

cause is DPoPException -> MfaVerifyException(
code = "dpop_error",
description = cause.message ?: "Error while attaching DPoP proof",
cause = cause
)

else -> MfaVerifyException(
code = Auth0Exception.UNKNOWN_ERROR,
description = cause.message ?: "Something went wrong",
cause = cause
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.auth0.android.authentication

import android.content.Context
import com.auth0.android.Auth0
import com.auth0.android.authentication.mfa.MfaApiClient
import com.auth0.android.authentication.mfa.MfaEnrollmentType
Expand All @@ -8,6 +9,11 @@ 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.dpop.DPoPException
import com.auth0.android.dpop.DPoPKeyStore
import com.auth0.android.dpop.DPoPUtil
import com.auth0.android.dpop.FakeECPrivateKey
import com.auth0.android.dpop.FakeECPublicKey
import com.auth0.android.request.internal.ThreadSwitcherShadow
import com.auth0.android.result.Authenticator
import com.auth0.android.result.Challenge
Expand All @@ -19,6 +25,8 @@ import com.auth0.android.util.SSLTestUtils
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
Expand Down Expand Up @@ -49,6 +57,8 @@ public class MfaApiClientTest {
private lateinit var auth0: Auth0
private lateinit var mfaClient: MfaApiClient
private lateinit var gson: Gson
private lateinit var mockKeyStore: DPoPKeyStore
private lateinit var mockContext: Context

@Before
public fun setUp(): Unit {
Expand All @@ -59,11 +69,16 @@ public class MfaApiClientTest {
auth0.networkingClient = SSLTestUtils.testClient
mfaClient = MfaApiClient(auth0, MFA_TOKEN)
gson = GsonBuilder().serializeNulls().create()
mockKeyStore = mock()
mockContext = mock()
whenever(mockContext.applicationContext).thenReturn(mockContext)
DPoPUtil.keyStore = mockKeyStore
}

@After
public fun tearDown(): Unit {
mockServer.shutdown()
DPoPUtil.keyStore = DPoPKeyStore()
}

private fun enqueueMockResponse(json: String, statusCode: Int = 200): Unit {
Expand Down Expand Up @@ -96,6 +111,146 @@ public class MfaApiClientTest {
assertThat(client, `is`(notNullValue()))
}

@Test
public fun shouldAttachDpopHeaderOnVerifyWhenDpopEnabledViaUseDPoP(): Unit = runTest {
whenever(mockKeyStore.hasKeyPair()).thenReturn(true)
whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey()))
val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext)
enqueueMockResponse(
"""{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
)

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

val request = mockServer.takeRequest()
assertThat(request.path, `is`("/oauth/token"))
assertThat(request.getHeader("DPoP"), `is`(notNullValue()))
}

@Test
public fun shouldAttachDpopHeaderOnVerifyWhenDpopInheritedFromAuthClient(): Unit = runTest {
whenever(mockKeyStore.hasKeyPair()).thenReturn(true)
whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey()))
val dpopClient = AuthenticationAPIClient(auth0).useDPoP(mockContext).mfaClient(MFA_TOKEN)
enqueueMockResponse(
"""{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
)

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

val request = mockServer.takeRequest()
assertThat(request.path, `is`("/oauth/token"))
assertThat(request.getHeader("DPoP"), `is`(notNullValue()))
}

@Test
public fun shouldNotAttachDpopHeaderOnVerifyWhenDpopDisabled(): Unit = runTest {
whenever(mockKeyStore.hasKeyPair()).thenReturn(false)
enqueueMockResponse(
"""{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
)

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

val request = mockServer.takeRequest()
assertThat(request.getHeader("DPoP"), `is`(nullValue()))
}

@Test
public fun shouldNotAttachDpopHeaderOnChallengeWhenDpopEnabled(): Unit = runTest {
whenever(mockKeyStore.hasKeyPair()).thenReturn(true)
whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey()))
val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext)
enqueueMockResponse("""{"challenge_type": "oob", "oob_code": "oob_123"}""")

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

val request = mockServer.takeRequest()
assertThat(request.path, `is`("/mfa/challenge"))
assertThat(request.getHeader("DPoP"), `is`(nullValue()))
}

@Test
public fun shouldNotAttachDpopHeaderOnEnrollWhenDpopEnabled(): Unit = runTest {
whenever(mockKeyStore.hasKeyPair()).thenReturn(true)
whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey()))
val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext)
enqueueMockResponse("""{"id": "sms|dev_123", "auth_session": "session_abc"}""")

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

val request = mockServer.takeRequest()
assertThat(request.path, `is`("/mfa/associate"))
assertThat(request.getHeader("DPoP"), `is`(nullValue()))
}

@Test
public fun shouldNotAttachDpopHeaderOnGetAuthenticatorsWhenDpopEnabled(): Unit = runTest {
whenever(mockKeyStore.hasKeyPair()).thenReturn(true)
whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey()))
val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext)
enqueueMockResponse("""[{"id": "sms|dev_123", "type": "oob", "active": true}]""")

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

val request = mockServer.takeRequest()
assertThat(request.path, `is`("/mfa/authenticators"))
assertThat(request.getHeader("DPoP"), `is`(nullValue()))
}

@Test
public fun shouldWrapDPoPExceptionAsMfaVerifyException(): Unit {
whenever(mockKeyStore.hasKeyPair()).thenReturn(true)
whenever(mockKeyStore.getKeyPair()).thenThrow(DPoPException.KEY_PAIR_NOT_FOUND)
val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext)

val exception = assertThrows(MfaVerifyException::class.java) {
runTest {
dpopClient.verify(MfaVerificationType.Otp("123456")).await()
}
}
assertThat(exception.getCode(), `is`("dpop_error"))
assertThat(mockServer.requestCount, `is`(0))
}

@Test
public fun shouldAttachDpopHeaderOnVerifyWhenDpopEnabledWithCallback(): Unit {
whenever(mockKeyStore.hasKeyPair()).thenReturn(true)
whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey()))
val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext)
enqueueMockResponse(
"""{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}"""
)

val callback = MockCallback<Credentials, MfaVerifyException>()
dpopClient.verify(MfaVerificationType.Otp("123456")).start(callback)
ShadowLooper.idleMainLooper()

assertThat(callback.getPayload(), `is`(notNullValue()))
assertThat(callback.getPayload().accessToken, `is`(ACCESS_TOKEN))
assertThat(callback.getError(), `is`(nullValue()))

val request = mockServer.takeRequest()
assertThat(request.path, `is`("/oauth/token"))
assertThat(request.getHeader("DPoP"), `is`(notNullValue()))
}

@Test
public fun shouldWrapDPoPExceptionAsMfaVerifyExceptionWithCallback(): Unit {
whenever(mockKeyStore.hasKeyPair()).thenReturn(true)
whenever(mockKeyStore.getKeyPair()).thenThrow(DPoPException.KEY_PAIR_NOT_FOUND)
val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext)

val callback = MockCallback<Credentials, MfaVerifyException>()
dpopClient.verify(MfaVerificationType.Otp("123456")).start(callback)
ShadowLooper.idleMainLooper()

assertThat(callback.getPayload(), `is`(nullValue()))
assertThat(callback.getError(), `is`(notNullValue()))
assertThat(callback.getError().getCode(), `is`("dpop_error"))
assertThat(mockServer.requestCount, `is`(0))
}


@Test
public fun shouldIncludeAuth0ClientHeaderInGetAuthenticators(): Unit = runTest {
Expand Down
Loading