From b033160230fc20b631e0e52b25932464f33d9ae8 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 21 May 2026 11:45:29 +0530 Subject: [PATCH 1/4] feat : Added support for type param for filtering authentication methods in MyAccount --- EXAMPLES.md | 32 +++++++++++-- .../myaccount/AuthenticationMethodType.kt | 16 +++++++ .../android/myaccount/MyAccountAPIClient.kt | 29 ++++++++++-- .../myaccount/MyAccountAPIClientTest.kt | 47 +++++++++++++++++++ 4 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt diff --git a/EXAMPLES.md b/EXAMPLES.md index 52ea9eae9..ad2ce546c 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2052,7 +2052,7 @@ myAccountClient.getFactors() ### Get All Enrolled Authentication Methods **Scopes required:** `read:me:authentication_methods` -Retrieves a detailed list of all the authentication methods that the current user has already enrolled in. +Retrieves a detailed list of all the authentication methods that the current user has already enrolled in. You can optionally filter the results by type using `AuthenticationMethodType`. **Prerequisites:** @@ -2060,10 +2060,20 @@ Retrieves a detailed list of all the authentication methods that the current use The user must have one or more authentication methods already enrolled. ```kotlin +// Get all authentication methods myAccountClient.getAuthenticationMethods() .start(object : Callback, MyAccountException> { - override fun onSuccess(result: AuthenticationMethods) { - // List of enrolled methods in result.authenticationMethods + override fun onSuccess(result: List) { + // List of enrolled methods + } + override fun onFailure(error: MyAccountException) { } + }) + +// Get authentication methods filtered by type +myAccountClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + .start(object : Callback, MyAccountException> { + override fun onSuccess(result: List) { + // List of enrolled passkey methods only } override fun onFailure(error: MyAccountException) { } }) @@ -2072,11 +2082,23 @@ myAccountClient.getAuthenticationMethods() Using Java ```java +// Get all authentication methods myAccountClient.getAuthenticationMethods() .start(new Callback, MyAccountException>() { @Override - public void onSuccess(AuthenticationMethods result) { - // List of enrolled methods in result.getAuthenticationMethods() + public void onSuccess(List result) { + // List of enrolled methods + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); + +// Get authentication methods filtered by type +myAccountClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + .start(new Callback, MyAccountException>() { + @Override + public void onSuccess(List result) { + // List of enrolled passkey methods only } @Override public void onFailure(@NonNull MyAccountException error) { } diff --git a/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt b/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt new file mode 100644 index 000000000..8f8002ba6 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt @@ -0,0 +1,16 @@ +package com.auth0.android.myaccount + +/** + * Represents the types of authentication methods supported by the My Account API. + */ +public enum class AuthenticationMethodType(public val type: String) { + PASSWORD("password"), + PASSKEY("passkey"), + TOTP("totp"), + PHONE("phone"), + EMAIL("email"), + PUSH("push-notification"), + RECOVERY_CODE("recovery-code"), + WEBAUTHN_PLATFORM("webauthn-platform"), + WEBAUTHN_ROAMING("webauthn-roaming") +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt index 0a71b1f66..269655b8c 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -24,7 +24,6 @@ import com.auth0.android.result.PasskeyEnrollmentChallenge import com.auth0.android.result.PasskeyRegistrationChallenge import com.auth0.android.result.RecoveryCodeEnrollmentChallenge import com.auth0.android.result.TotpEnrollmentChallenge - import com.google.gson.Gson import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -256,6 +255,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). * Please reach out to Auth0 support to get it enabled for your tenant. * + * ## Scopes Required + * + * `read:me:authentication_methods` * * ## Usage * @@ -263,7 +265,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") * val apiClient = MyAccountAPIClient(auth0, accessToken) * - * + * // Get all authentication methods * apiClient.getAuthenticationMethods() * .start(object : Callback, MyAccountException> { * override fun onSuccess(result: List) { @@ -274,11 +276,30 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * Log.e("MyApp", "Failed with: ${error.message}") * } * }) + * + * // Get authentication methods filtered by type + * apiClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + * .start(object : Callback, MyAccountException> { + * override fun onSuccess(result: List) { + * Log.d("MyApp", "Passkey methods: $result") + * } + * + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) * ``` * + * @param type Optional filter to retrieve only authentication methods of a specific type. + * @return A request to get the list of authentication methods. + * */ - public fun getAuthenticationMethods(): Request, MyAccountException> { - val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + @JvmOverloads + public fun getAuthenticationMethods(type: AuthenticationMethodType? = null): Request, MyAccountException> { + val url = getDomainUrlBuilder().apply { + addPathSegment(AUTHENTICATION_METHODS) + type?.let { addQueryParameter(TYPE_KEY, it.type) } + }.build() val listAdapter = object : JsonAdapter> { override fun fromJson( diff --git a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt index 2d4beb419..191060340 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -345,6 +345,53 @@ public class MyAccountAPIClientTest { assertThat(request.method, Matchers.equalTo("GET")) } + @Test + public fun `getAuthenticationMethods should include type query parameter when specified`() { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(AuthenticationMethodType.PASSKEY).start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods?type=passkey")) + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `getAuthenticationMethods should not include type query parameter when null`() { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(null).start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `getAuthenticationMethods should include correct type value for each AuthenticationMethodType`() { + val typesToExpected = mapOf( + AuthenticationMethodType.PHONE to "phone", + AuthenticationMethodType.EMAIL to "email", + AuthenticationMethodType.TOTP to "totp", + AuthenticationMethodType.PUSH to "push-notification", + AuthenticationMethodType.RECOVERY_CODE to "recovery-code", + AuthenticationMethodType.PASSWORD to "password", + AuthenticationMethodType.WEBAUTHN_PLATFORM to "webauthn-platform", + AuthenticationMethodType.WEBAUTHN_ROAMING to "webauthn-roaming" + ) + + for ((type, expected) in typesToExpected) { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(type).start(callback) + + val request = mockAPI.takeRequest() + assertThat( + "type=$expected should be in query", + request.path, + Matchers.equalTo("/me/v1/authentication-methods?type=$expected") + ) + } + } + @Test public fun `getAuthenticationMethodById should build correct URL and Authorization header`() { val callback = MockMyAccountCallback() From 7627d878038c0606e5d09972280a689ee33c0f46 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 21 May 2026 12:37:22 +0530 Subject: [PATCH 2/4] Enabled dpop token support --- EXAMPLES.md | 24 ++++ .../android/myaccount/MyAccountAPIClient.kt | 82 +++++++++----- .../myaccount/MyAccountAPIClientTest.kt | 107 ++++++++++++++++++ 3 files changed, 186 insertions(+), 27 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index ad2ce546c..d26eb1bdf 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1845,6 +1845,30 @@ Use the Auth0 My Account API to manage the current user's account. To call the My Account API, you need an access token issued specifically for this API, including any required scopes for the operations you want to perform. See [API credentials [EA]](#api-credentials-ea) to learn how to obtain one. +```kotlin +val client = MyAccountAPIClient(auth0, accessToken) +``` + +#### Using DPoP + +If your application uses [DPoP (Demonstrating Proof of Possession)](https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-authorization-code-flow-with-dpop), you can enable it on the My Account API client: + +```kotlin +val client = MyAccountAPIClient(auth0, accessToken).useDPoP(context) +``` + +When DPoP is enabled, the client will automatically: +- Use the `DPoP` authorization scheme instead of `Bearer` +- Include a DPoP proof header on every request + +
+ Using Java + +```java +MyAccountAPIClient client = new MyAccountAPIClient(auth0, accessToken).useDPoP(context); +``` +
+ ### Enroll a new passkey **Scopes required:** `create:me:authentication_methods` diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt index 269655b8c..14ad4b4c6 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -1,10 +1,13 @@ package com.auth0.android.myaccount +import android.content.Context import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.ParameterBuilder +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.SenderConstraining import com.auth0.android.request.ErrorAdapter import com.auth0.android.request.JsonAdapter import com.auth0.android.request.PublicKeyCredentials @@ -51,7 +54,12 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private val accessToken: String, private val factory: RequestFactory, private val gson: Gson -) { +) : SenderConstraining { + + private var dPoP: DPoP? = null + + private val authorizationHeader: String + get() = if (dPoP != null) "DPoP $accessToken" else "Bearer $accessToken" /** * Creates a new MyAccountAPI client instance. @@ -74,6 +82,25 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting GsonProvider.gson ) + /** + * Enable DPoP (Demonstrating Proof of Possession) for this client. + * + * When enabled, requests will include a DPoP proof header and the Authorization header + * will use the "DPoP" scheme instead of "Bearer". + * + * Example usage: + * ```kotlin + * val client = MyAccountAPIClient(auth0, accessToken).useDPoP(context) + * ``` + * + * @param context the Android context + * @return this client instance for chaining + */ + override fun useDPoP(context: Context): MyAccountAPIClient { + dPoP = DPoP(context) + return this + } + /** * Requests a challenge for enrolling a new passkey. This is the first part of the enrollment flow. @@ -170,9 +197,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting ) } } - return factory.post(url.toString(), passkeyEnrollmentAdapter) + return factory.post(url.toString(), passkeyEnrollmentAdapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -238,11 +265,12 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return factory.post( url.toString(), - GsonAdapter(PasskeyAuthenticationMethod::class.java, gson) + GsonAdapter(PasskeyAuthenticationMethod::class.java, gson), + dPoP ) .addParameters(params) .addParameter(AUTHN_RESPONSE_KEY, authnResponse) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } @@ -310,8 +338,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return container.authenticationMethods } } - return factory.get(url.toString(), listAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), listAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } @@ -352,8 +380,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(AUTHENTICATION_METHODS) .addPathSegment(authenticationMethodId) .build() - return factory.get(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -411,9 +439,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting } }.asDictionary() - return factory.patch(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.patch(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } @@ -459,8 +487,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val voidAdapter = object : JsonAdapter { override fun fromJson(reader: Reader, metadata: Map): Void? = null } - return factory.delete(url.toString(), voidAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.delete(url.toString(), voidAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -496,8 +524,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return container.factors } } - return factory.get(url.toString(), listAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), listAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -600,9 +628,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "totp").asDictionary() val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() val adapter = GsonAdapter(TotpEnrollmentChallenge::class.java, gson) - return factory.post(url.toString(), adapter) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -633,9 +661,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() // The response structure for push notification challenge is the same as TOTP (contains barcode_uri) val adapter = GsonAdapter(TotpEnrollmentChallenge::class.java, gson) - return factory.post(url.toString(), adapter) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -665,9 +693,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "recovery-code").asDictionary() val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() val adapter = GsonAdapter(RecoveryCodeEnrollmentChallenge::class.java, gson) - return factory.post(url.toString(), adapter) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -708,9 +736,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(VERIFY) .build() val params = mapOf("otp_code" to otpCode, AUTH_SESSION_KEY to authSession) - return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -748,9 +776,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(VERIFY) .build() val params = mapOf(AUTH_SESSION_KEY to authSession) - return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } // WebAuthn methods are private. @@ -811,9 +839,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private fun buildEnrollmentRequest(params: Map): Request { val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() - return factory.post(url.toString(), GsonAdapter(EnrollmentChallenge::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(EnrollmentChallenge::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } private fun getDomainUrlBuilder(): HttpUrl.Builder { diff --git a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt index 191060340..76a095428 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -1,6 +1,11 @@ package com.auth0.android.myaccount +import android.content.Context import com.auth0.android.Auth0 +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.PublicKeyCredentials import com.auth0.android.request.Response import com.auth0.android.result.AuthenticationMethod @@ -18,6 +23,7 @@ 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 okhttp3.mockwebserver.RecordedRequest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers @@ -36,11 +42,17 @@ public class MyAccountAPIClientTest { private lateinit var client: MyAccountAPIClient private lateinit var gson: Gson private lateinit var mockAPI: MyAccountAPIMockServer + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context @Before public fun setUp() { mockAPI = MyAccountAPIMockServer() MockitoAnnotations.openMocks(this) + mockKeyStore = mock() + mockContext = mock() + whenever(mockContext.applicationContext).thenReturn(mockContext) + DPoPUtil.keyStore = mockKeyStore gson = GsonBuilder().serializeNulls().create() client = MyAccountAPIClient(auth0, ACCESS_TOKEN) } @@ -532,6 +544,101 @@ public class MyAccountAPIClientTest { assertThat(body, Matchers.hasEntry("type", "push-notification" as Any)) } + // DPoP tests + + @Test + public fun `should use Bearer authorization header when DPoP is not enabled`() { + val callback = MockMyAccountCallback>() + client.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should use DPoP authorization header when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback>() + dpopClient.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + } + + @Test + public fun `should include DPoP proof header on POST requests when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.enrollEmail("test@example.com").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("POST")) + } + + @Test + public fun `should not include DPoP proof header when DPoP is not enabled`() { + val callback = MockMyAccountCallback() + client.enrollEmail("test@example.com").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should not include DPoP proof header when DPoP is enabled but no key pair exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback>() + dpopClient.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should use DPoP authorization header on PATCH requests when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.updateAuthenticationMethodById("method|123", authenticationMethodName = "Test") + .start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("PATCH")) + } + + @Test + public fun `should use DPoP authorization header on DELETE requests when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.deleteAuthenticationMethod("method|123").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("DELETE")) + } + private fun bodyFromRequest(request: RecordedRequest): Map { val mapType = object : TypeToken?>() {}.type return gson.fromJson(request.body.readUtf8(), mapType) From 9fd8d60278984ad524ebe0607959ac44cba7d3f7 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 21 May 2026 11:45:29 +0530 Subject: [PATCH 3/4] feat : Added support for type param for filtering authentication methods in MyAccount --- EXAMPLES.md | 32 +++++++++++-- .../myaccount/AuthenticationMethodType.kt | 16 +++++++ .../android/myaccount/MyAccountAPIClient.kt | 29 ++++++++++-- .../myaccount/MyAccountAPIClientTest.kt | 47 +++++++++++++++++++ 4 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt diff --git a/EXAMPLES.md b/EXAMPLES.md index 52ea9eae9..ad2ce546c 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2052,7 +2052,7 @@ myAccountClient.getFactors() ### Get All Enrolled Authentication Methods **Scopes required:** `read:me:authentication_methods` -Retrieves a detailed list of all the authentication methods that the current user has already enrolled in. +Retrieves a detailed list of all the authentication methods that the current user has already enrolled in. You can optionally filter the results by type using `AuthenticationMethodType`. **Prerequisites:** @@ -2060,10 +2060,20 @@ Retrieves a detailed list of all the authentication methods that the current use The user must have one or more authentication methods already enrolled. ```kotlin +// Get all authentication methods myAccountClient.getAuthenticationMethods() .start(object : Callback, MyAccountException> { - override fun onSuccess(result: AuthenticationMethods) { - // List of enrolled methods in result.authenticationMethods + override fun onSuccess(result: List) { + // List of enrolled methods + } + override fun onFailure(error: MyAccountException) { } + }) + +// Get authentication methods filtered by type +myAccountClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + .start(object : Callback, MyAccountException> { + override fun onSuccess(result: List) { + // List of enrolled passkey methods only } override fun onFailure(error: MyAccountException) { } }) @@ -2072,11 +2082,23 @@ myAccountClient.getAuthenticationMethods() Using Java ```java +// Get all authentication methods myAccountClient.getAuthenticationMethods() .start(new Callback, MyAccountException>() { @Override - public void onSuccess(AuthenticationMethods result) { - // List of enrolled methods in result.getAuthenticationMethods() + public void onSuccess(List result) { + // List of enrolled methods + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); + +// Get authentication methods filtered by type +myAccountClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + .start(new Callback, MyAccountException>() { + @Override + public void onSuccess(List result) { + // List of enrolled passkey methods only } @Override public void onFailure(@NonNull MyAccountException error) { } diff --git a/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt b/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt new file mode 100644 index 000000000..8f8002ba6 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt @@ -0,0 +1,16 @@ +package com.auth0.android.myaccount + +/** + * Represents the types of authentication methods supported by the My Account API. + */ +public enum class AuthenticationMethodType(public val type: String) { + PASSWORD("password"), + PASSKEY("passkey"), + TOTP("totp"), + PHONE("phone"), + EMAIL("email"), + PUSH("push-notification"), + RECOVERY_CODE("recovery-code"), + WEBAUTHN_PLATFORM("webauthn-platform"), + WEBAUTHN_ROAMING("webauthn-roaming") +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt index 0a71b1f66..269655b8c 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -24,7 +24,6 @@ import com.auth0.android.result.PasskeyEnrollmentChallenge import com.auth0.android.result.PasskeyRegistrationChallenge import com.auth0.android.result.RecoveryCodeEnrollmentChallenge import com.auth0.android.result.TotpEnrollmentChallenge - import com.google.gson.Gson import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -256,6 +255,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). * Please reach out to Auth0 support to get it enabled for your tenant. * + * ## Scopes Required + * + * `read:me:authentication_methods` * * ## Usage * @@ -263,7 +265,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") * val apiClient = MyAccountAPIClient(auth0, accessToken) * - * + * // Get all authentication methods * apiClient.getAuthenticationMethods() * .start(object : Callback, MyAccountException> { * override fun onSuccess(result: List) { @@ -274,11 +276,30 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * Log.e("MyApp", "Failed with: ${error.message}") * } * }) + * + * // Get authentication methods filtered by type + * apiClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + * .start(object : Callback, MyAccountException> { + * override fun onSuccess(result: List) { + * Log.d("MyApp", "Passkey methods: $result") + * } + * + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) * ``` * + * @param type Optional filter to retrieve only authentication methods of a specific type. + * @return A request to get the list of authentication methods. + * */ - public fun getAuthenticationMethods(): Request, MyAccountException> { - val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + @JvmOverloads + public fun getAuthenticationMethods(type: AuthenticationMethodType? = null): Request, MyAccountException> { + val url = getDomainUrlBuilder().apply { + addPathSegment(AUTHENTICATION_METHODS) + type?.let { addQueryParameter(TYPE_KEY, it.type) } + }.build() val listAdapter = object : JsonAdapter> { override fun fromJson( diff --git a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt index 2d4beb419..191060340 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -345,6 +345,53 @@ public class MyAccountAPIClientTest { assertThat(request.method, Matchers.equalTo("GET")) } + @Test + public fun `getAuthenticationMethods should include type query parameter when specified`() { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(AuthenticationMethodType.PASSKEY).start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods?type=passkey")) + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `getAuthenticationMethods should not include type query parameter when null`() { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(null).start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `getAuthenticationMethods should include correct type value for each AuthenticationMethodType`() { + val typesToExpected = mapOf( + AuthenticationMethodType.PHONE to "phone", + AuthenticationMethodType.EMAIL to "email", + AuthenticationMethodType.TOTP to "totp", + AuthenticationMethodType.PUSH to "push-notification", + AuthenticationMethodType.RECOVERY_CODE to "recovery-code", + AuthenticationMethodType.PASSWORD to "password", + AuthenticationMethodType.WEBAUTHN_PLATFORM to "webauthn-platform", + AuthenticationMethodType.WEBAUTHN_ROAMING to "webauthn-roaming" + ) + + for ((type, expected) in typesToExpected) { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(type).start(callback) + + val request = mockAPI.takeRequest() + assertThat( + "type=$expected should be in query", + request.path, + Matchers.equalTo("/me/v1/authentication-methods?type=$expected") + ) + } + } + @Test public fun `getAuthenticationMethodById should build correct URL and Authorization header`() { val callback = MockMyAccountCallback() From 040b09501a4ba71ed30ade73f2baeb151daf2d45 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 21 May 2026 12:37:22 +0530 Subject: [PATCH 4/4] Enabled dpop token support --- EXAMPLES.md | 24 ++++ .../android/myaccount/MyAccountAPIClient.kt | 82 +++++++++----- .../myaccount/MyAccountAPIClientTest.kt | 107 ++++++++++++++++++ 3 files changed, 186 insertions(+), 27 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index ad2ce546c..d26eb1bdf 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1845,6 +1845,30 @@ Use the Auth0 My Account API to manage the current user's account. To call the My Account API, you need an access token issued specifically for this API, including any required scopes for the operations you want to perform. See [API credentials [EA]](#api-credentials-ea) to learn how to obtain one. +```kotlin +val client = MyAccountAPIClient(auth0, accessToken) +``` + +#### Using DPoP + +If your application uses [DPoP (Demonstrating Proof of Possession)](https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-authorization-code-flow-with-dpop), you can enable it on the My Account API client: + +```kotlin +val client = MyAccountAPIClient(auth0, accessToken).useDPoP(context) +``` + +When DPoP is enabled, the client will automatically: +- Use the `DPoP` authorization scheme instead of `Bearer` +- Include a DPoP proof header on every request + +
+ Using Java + +```java +MyAccountAPIClient client = new MyAccountAPIClient(auth0, accessToken).useDPoP(context); +``` +
+ ### Enroll a new passkey **Scopes required:** `create:me:authentication_methods` diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt index 269655b8c..14ad4b4c6 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -1,10 +1,13 @@ package com.auth0.android.myaccount +import android.content.Context import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.ParameterBuilder +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.SenderConstraining import com.auth0.android.request.ErrorAdapter import com.auth0.android.request.JsonAdapter import com.auth0.android.request.PublicKeyCredentials @@ -51,7 +54,12 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private val accessToken: String, private val factory: RequestFactory, private val gson: Gson -) { +) : SenderConstraining { + + private var dPoP: DPoP? = null + + private val authorizationHeader: String + get() = if (dPoP != null) "DPoP $accessToken" else "Bearer $accessToken" /** * Creates a new MyAccountAPI client instance. @@ -74,6 +82,25 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting GsonProvider.gson ) + /** + * Enable DPoP (Demonstrating Proof of Possession) for this client. + * + * When enabled, requests will include a DPoP proof header and the Authorization header + * will use the "DPoP" scheme instead of "Bearer". + * + * Example usage: + * ```kotlin + * val client = MyAccountAPIClient(auth0, accessToken).useDPoP(context) + * ``` + * + * @param context the Android context + * @return this client instance for chaining + */ + override fun useDPoP(context: Context): MyAccountAPIClient { + dPoP = DPoP(context) + return this + } + /** * Requests a challenge for enrolling a new passkey. This is the first part of the enrollment flow. @@ -170,9 +197,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting ) } } - return factory.post(url.toString(), passkeyEnrollmentAdapter) + return factory.post(url.toString(), passkeyEnrollmentAdapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -238,11 +265,12 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return factory.post( url.toString(), - GsonAdapter(PasskeyAuthenticationMethod::class.java, gson) + GsonAdapter(PasskeyAuthenticationMethod::class.java, gson), + dPoP ) .addParameters(params) .addParameter(AUTHN_RESPONSE_KEY, authnResponse) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } @@ -310,8 +338,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return container.authenticationMethods } } - return factory.get(url.toString(), listAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), listAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } @@ -352,8 +380,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(AUTHENTICATION_METHODS) .addPathSegment(authenticationMethodId) .build() - return factory.get(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -411,9 +439,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting } }.asDictionary() - return factory.patch(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.patch(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } @@ -459,8 +487,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val voidAdapter = object : JsonAdapter { override fun fromJson(reader: Reader, metadata: Map): Void? = null } - return factory.delete(url.toString(), voidAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.delete(url.toString(), voidAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -496,8 +524,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return container.factors } } - return factory.get(url.toString(), listAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), listAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -600,9 +628,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "totp").asDictionary() val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() val adapter = GsonAdapter(TotpEnrollmentChallenge::class.java, gson) - return factory.post(url.toString(), adapter) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -633,9 +661,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() // The response structure for push notification challenge is the same as TOTP (contains barcode_uri) val adapter = GsonAdapter(TotpEnrollmentChallenge::class.java, gson) - return factory.post(url.toString(), adapter) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -665,9 +693,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "recovery-code").asDictionary() val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() val adapter = GsonAdapter(RecoveryCodeEnrollmentChallenge::class.java, gson) - return factory.post(url.toString(), adapter) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -708,9 +736,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(VERIFY) .build() val params = mapOf("otp_code" to otpCode, AUTH_SESSION_KEY to authSession) - return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -748,9 +776,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(VERIFY) .build() val params = mapOf(AUTH_SESSION_KEY to authSession) - return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } // WebAuthn methods are private. @@ -811,9 +839,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private fun buildEnrollmentRequest(params: Map): Request { val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() - return factory.post(url.toString(), GsonAdapter(EnrollmentChallenge::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(EnrollmentChallenge::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } private fun getDomainUrlBuilder(): HttpUrl.Builder { diff --git a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt index 191060340..76a095428 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -1,6 +1,11 @@ package com.auth0.android.myaccount +import android.content.Context import com.auth0.android.Auth0 +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.PublicKeyCredentials import com.auth0.android.request.Response import com.auth0.android.result.AuthenticationMethod @@ -18,6 +23,7 @@ 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 okhttp3.mockwebserver.RecordedRequest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers @@ -36,11 +42,17 @@ public class MyAccountAPIClientTest { private lateinit var client: MyAccountAPIClient private lateinit var gson: Gson private lateinit var mockAPI: MyAccountAPIMockServer + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context @Before public fun setUp() { mockAPI = MyAccountAPIMockServer() MockitoAnnotations.openMocks(this) + mockKeyStore = mock() + mockContext = mock() + whenever(mockContext.applicationContext).thenReturn(mockContext) + DPoPUtil.keyStore = mockKeyStore gson = GsonBuilder().serializeNulls().create() client = MyAccountAPIClient(auth0, ACCESS_TOKEN) } @@ -532,6 +544,101 @@ public class MyAccountAPIClientTest { assertThat(body, Matchers.hasEntry("type", "push-notification" as Any)) } + // DPoP tests + + @Test + public fun `should use Bearer authorization header when DPoP is not enabled`() { + val callback = MockMyAccountCallback>() + client.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should use DPoP authorization header when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback>() + dpopClient.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + } + + @Test + public fun `should include DPoP proof header on POST requests when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.enrollEmail("test@example.com").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("POST")) + } + + @Test + public fun `should not include DPoP proof header when DPoP is not enabled`() { + val callback = MockMyAccountCallback() + client.enrollEmail("test@example.com").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should not include DPoP proof header when DPoP is enabled but no key pair exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback>() + dpopClient.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should use DPoP authorization header on PATCH requests when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.updateAuthenticationMethodById("method|123", authenticationMethodName = "Test") + .start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("PATCH")) + } + + @Test + public fun `should use DPoP authorization header on DELETE requests when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.deleteAuthenticationMethod("method|123").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("DELETE")) + } + private fun bodyFromRequest(request: RecordedRequest): Map { val mapType = object : TypeToken?>() {}.type return gson.fromJson(request.body.readUtf8(), mapType)