diff --git a/EXAMPLES.md b/EXAMPLES.md
index d667ac4ee..6ad20c050 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -682,6 +682,32 @@ MfaApiClient mfaClient = authentication.mfaClient(mfaToken);
```
+##### 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)
+```
+
+
+ Using Java
+
+```java
+MfaApiClient mfaClient = new MfaApiClient(account, mfaToken).useDPoP(context);
+```
+
+
+> [!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.
diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt
index b80f34873..6fbdbf842 100755
--- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt
+++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt
@@ -113,10 +113,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)
}
/**
diff --git a/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt
index d22503a63..5dced6da8 100644
--- a/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt
+++ b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt
@@ -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
@@ -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
@@ -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 {
+
+ /**
+ * 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 by lazy {
@@ -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)
}
@@ -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
diff --git a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt
index acd415cd1..681758dcc 100644
--- a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt
+++ b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt
@@ -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
@@ -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
@@ -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 com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.whenever
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
@@ -31,6 +39,7 @@ import org.hamcrest.Matchers.instanceOf
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.notNullValue
import org.hamcrest.Matchers.nullValue
+import org.hamcrest.Matchers.sameInstance
import org.junit.After
import org.junit.Assert.assertThrows
import org.junit.Before
@@ -49,6 +58,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 {
@@ -59,11 +70,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 {
@@ -96,6 +112,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()
+ 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()
+ 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 {