From cf3ed64babe88ac13e91052f47a2d5e620f41cde Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 29 Jun 2026 12:33:42 +0530 Subject: [PATCH 1/3] feat: Added DPoP support for MFA APIs --- .../authentication/AuthenticationAPIClient.kt | 2 +- .../authentication/mfa/MfaApiClient.kt | 35 ++++-- .../authentication/MfaApiClientTest.kt | 116 ++++++++++++++++++ 3 files changed, 145 insertions(+), 8 deletions(-) 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..d92483a4a 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -116,7 +116,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @return A new [MfaApiClient] instance configured for the transaction. */ 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..d49214b56 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,10 @@ 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.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 +24,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 +38,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 +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 { @@ -59,6 +69,10 @@ 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 @@ -96,6 +110,108 @@ 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()).thenReturn(null) + 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 shouldIncludeAuth0ClientHeaderInGetAuthenticators(): Unit = runTest { From 99877b8f31721797c8a564a321ecc41a958d6707 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 29 Jun 2026 12:56:13 +0530 Subject: [PATCH 2/3] Added few more test cases and updated the Examples file --- EXAMPLES.md | 26 +++++++++++++ .../authentication/MfaApiClientTest.kt | 39 +++++++++++++++++++ 2 files changed, 65 insertions(+) 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/test/java/com/auth0/android/authentication/MfaApiClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt index d49214b56..a0593d6c5 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt @@ -78,6 +78,7 @@ public class MfaApiClientTest { @After public fun tearDown(): Unit { mockServer.shutdown() + DPoPUtil.keyStore = DPoPKeyStore() } private fun enqueueMockResponse(json: String, statusCode: Int = 200): Unit { @@ -212,6 +213,44 @@ public class MfaApiClientTest { 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()).thenReturn(null) + 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 { From c40e54b106aa6e13d79fd8799197651920539f06 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 29 Jun 2026 14:08:01 +0530 Subject: [PATCH 3/3] addressed few minor review comments --- .../auth0/android/authentication/AuthenticationAPIClient.kt | 3 ++- .../com/auth0/android/authentication/MfaApiClientTest.kt | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 d92483a4a..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,7 +113,8 @@ 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, gson, this.dPoP) 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 a0593d6c5..681758dcc 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt @@ -9,6 +9,7 @@ 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 @@ -201,7 +202,7 @@ public class MfaApiClientTest { @Test public fun shouldWrapDPoPExceptionAsMfaVerifyException(): Unit { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) - whenever(mockKeyStore.getKeyPair()).thenReturn(null) + whenever(mockKeyStore.getKeyPair()).thenThrow(DPoPException.KEY_PAIR_NOT_FOUND) val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) val exception = assertThrows(MfaVerifyException::class.java) { @@ -238,7 +239,7 @@ public class MfaApiClientTest { @Test public fun shouldWrapDPoPExceptionAsMfaVerifyExceptionWithCallback(): Unit { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) - whenever(mockKeyStore.getKeyPair()).thenReturn(null) + whenever(mockKeyStore.getKeyPair()).thenThrow(DPoPException.KEY_PAIR_NOT_FOUND) val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) val callback = MockCallback()