From fb019c7be366b3ceb07bca71e87635d66e90b646 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 16 Feb 2026 15:38:10 +0530 Subject: [PATCH 1/4] breaking : Moved the method in the WebAuthProvider class to the login builder to scope it to individualk login requests --- EXAMPLES.md | 4 +- V4_MIGRATION_GUIDE.md | 26 ++++++++++ .../auth0/android/provider/WebAuthProvider.kt | 24 +++++---- .../android/provider/WebAuthProviderTest.kt | 51 ++++++++----------- 4 files changed, 64 insertions(+), 41 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 5dd8b9f9..cc4487ea 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -233,12 +233,12 @@ WebAuthProvider.login(account) > [!NOTE] > This feature is currently available in [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. -[DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP()` method. +[DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP(context)` method on the login Builder. ```kotlin WebAuthProvider - .useDPoP() .login(account) + .useDPoP(requireContext()) .start(requireContext(), object : Callback { override fun onSuccess(result: Credentials) { println("Credentials $result") diff --git a/V4_MIGRATION_GUIDE.md b/V4_MIGRATION_GUIDE.md index 880c32d9..03dc7741 100644 --- a/V4_MIGRATION_GUIDE.md +++ b/V4_MIGRATION_GUIDE.md @@ -67,6 +67,32 @@ buildscript { - [signinWithPasskey()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L235-L253) - Sign in a user using passkeys - [signupWithPasskey()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L319-L344) - Sign up a user and returns a challenge for key generation +### DPoP Configuration Moved to Builder + +The `useDPoP()` method has been moved from the `WebAuthProvider` object to the login `Builder` class. This change allows DPoP to be configured per-request instead of globally. + +**v3 (global configuration — no longer supported):** + +```kotlin +// ❌ This no longer works +WebAuthProvider + .useDPoP(context) + .login(account) + .start(context, callback) +``` + +**v4 (builder-based configuration — required):** + +```kotlin +// ✅ Use this instead +WebAuthProvider + .login(account) + .useDPoP(context) + .start(context, callback) +``` + +This change ensures that DPoP configuration is scoped to individual login requests rather than persisting across the entire application lifecycle. + ## Dependency Changes ### ⚠️ Gson 2.8.9 → 2.11.0 (Transitive Dependency) diff --git a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt index 955eec9c..307db3dc 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -27,10 +27,9 @@ import kotlin.coroutines.resumeWithException * * It uses an external browser by sending the [android.content.Intent.ACTION_VIEW] intent. */ -public object WebAuthProvider : SenderConstraining { +public object WebAuthProvider { private val TAG: String? = WebAuthProvider::class.simpleName private const val KEY_BUNDLE_OAUTH_MANAGER_STATE = "oauth_manager_state" - private var dPoP : DPoP? = null private val callbacks = CopyOnWriteArraySet>() @@ -49,12 +48,6 @@ public object WebAuthProvider : SenderConstraining { callbacks -= callback } - // Public methods - public override fun useDPoP(context: Context): WebAuthProvider { - dPoP = DPoP(context) - return this - } - /** * Initialize the WebAuthProvider instance for logging out the user using an account. Additional settings can be configured * in the LogoutBuilder, like changing the scheme of the return to URL. @@ -305,7 +298,7 @@ public object WebAuthProvider : SenderConstraining { } } - public class Builder internal constructor(private val account: Auth0) { + public class Builder internal constructor(private val account: Auth0) : SenderConstraining { private val values: MutableMap = mutableMapOf() private val headers: MutableMap = mutableMapOf() private var pkce: PKCE? = null @@ -313,6 +306,7 @@ public object WebAuthProvider : SenderConstraining { private var scheme: String = "https" private var redirectUri: String? = null private var invitationUrl: String? = null + private var dPoP: DPoP? = null private var ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() private var leeway: Int? = null private var launchAsTwa: Boolean = false @@ -548,6 +542,18 @@ public object WebAuthProvider : SenderConstraining { return this } + /** + * Enable DPoP (Demonstrating Proof-of-Possession) for this authentication request. + * DPoP binds access tokens to the client's cryptographic key, providing enhanced security. + * + * @param context the Android context used to access the keystore for DPoP key management + * @return the current builder instance + */ + public override fun useDPoP(context: Context): Builder { + dPoP = DPoP(context) + return this + } + /** * Request user Authentication. The result will be received in the callback. * An error is raised if there are no browser applications installed in the device, or if diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt index 94baa6b3..6476f3c3 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -331,8 +331,8 @@ public class WebAuthProviderTest { @Test public fun enablingDPoPWillGenerateNewKeyPairIfOneDoesNotExist() { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) verify(mockKeyStore).generateKeyPair(any(), any()) } @@ -358,8 +358,8 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -2741,21 +2741,13 @@ public class WebAuthProviderTest { //DPoP - public fun shouldReturnSameInstanceWhenCallingUseDPoPMultipleTimes() { - val provider1 = WebAuthProvider.useDPoP(mockContext) - val provider2 = WebAuthProvider.useDPoP(mockContext) - - assertThat(provider1, `is`(provider2)) - assertThat(WebAuthProvider.useDPoP(mockContext), `is`(provider1)) - } - @Test public fun shouldPassDPoPInstanceToOAuthManagerWhenDPoPIsEnabled() { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) val managerInstance = WebAuthProvider.managerInstance as OAuthManager @@ -2775,8 +2767,8 @@ public class WebAuthProviderTest { public fun shouldGenerateKeyPairWhenDPoPIsEnabledAndNoKeyPairExists() { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) verify(mockKeyStore).generateKeyPair(any(), any()) @@ -2787,8 +2779,8 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) verify(mockKeyStore, never()).generateKeyPair(any(), any()) @@ -2809,8 +2801,8 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -2829,8 +2821,8 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(null) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -2845,8 +2837,8 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - val builder = WebAuthProvider.useDPoP(mockContext) - .login(account) + val builder = login(account) + .useDPoP(mockContext) .withConnection("test-connection") builder.start(activity, callback) @@ -2861,8 +2853,7 @@ public class WebAuthProviderTest { @Test public fun shouldNotAffectLogoutWhenDPoPIsEnabled() { - WebAuthProvider.useDPoP(mockContext) - .logout(account) + logout(account) .start(activity, voidCallback) verify(activity).startActivity(intentCaptor.capture()) @@ -2879,8 +2870,8 @@ public class WebAuthProviderTest { doThrow(DPoPException.KEY_GENERATION_ERROR) .`when`(mockKeyStore).generateKeyPair(any(), any()) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) // Verify that the authentication fails when DPoP key generation fails @@ -2909,8 +2900,8 @@ public class WebAuthProviderTest { val proxyAccount: Auth0 = Auth0.getInstance(JwtTestUtils.EXPECTED_AUDIENCE, mockAPI.domain) proxyAccount.networkingClient = SSLTestUtils.testClient - WebAuthProvider.useDPoP(mockContext) - .login(proxyAccount) + login(proxyAccount) + .useDPoP(mockContext) .withPKCE(pkce) .start(activity, authCallback) From c8eb947dfd34363d0ed58a27115c9862413438d2 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 16 Feb 2026 15:56:45 +0530 Subject: [PATCH 2/4] Updated a test cases --- .../android/provider/WebAuthProviderTest.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt index 6476f3c3..24e4d915 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -2885,6 +2885,38 @@ public class WebAuthProviderTest { verify(activity, never()).startActivity(any()) } + @Test + public fun shouldNotApplyDPoPToSubsequentLoginCallsWhenNotExplicitlyEnabled() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + // First login with DPoP enabled + login(account) + .useDPoP(mockContext) + .withScope("openid profile") + .start(activity, callback) + + verify(activity).startActivity(intentCaptor.capture()) + val firstUri = + intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + assertThat(firstUri, `is`(notNullValue())) + assertThat(firstUri, UriMatchers.hasParamWithName("dpop_jkt")) + + // Reset the manager instance to simulate a fresh login flow + WebAuthProvider.resetManagerInstance() + + // Second login WITHOUT DPoP - should not have dpop_jkt + login(account) + .withScope("openid email") + .start(activity, callback) + + verify(activity, times(2)).startActivity(intentCaptor.capture()) + val secondUri = + intentCaptor.allValues[1].getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + assertThat(secondUri, `is`(notNullValue())) + assertThat(secondUri, not(UriMatchers.hasParamWithName("dpop_jkt"))) + } + @Test @Throws(Exception::class) public fun shouldResumeLoginSuccessfullyWithDPoPEnabled() { From 507f0c9ef3e3ed14709dccbcc1d468eff1dbbeac Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 16 Feb 2026 16:32:13 +0530 Subject: [PATCH 3/4] Fixed the failing test --- .../com/auth0/android/provider/WebAuthProviderTest.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt index 24e4d915..550f2572 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -2901,19 +2901,21 @@ public class WebAuthProviderTest { intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) assertThat(firstUri, `is`(notNullValue())) assertThat(firstUri, UriMatchers.hasParamWithName("dpop_jkt")) + assertThat(firstUri?.getQueryParameter("scope"), `is`("openid profile")) - // Reset the manager instance to simulate a fresh login flow + // Reset the manager instance and captor for the second call WebAuthProvider.resetManagerInstance() - // Second login WITHOUT DPoP - should not have dpop_jkt login(account) .withScope("openid email") .start(activity, callback) + // Verify second startActivity call verify(activity, times(2)).startActivity(intentCaptor.capture()) val secondUri = - intentCaptor.allValues[1].getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + intentCaptor.lastValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) assertThat(secondUri, `is`(notNullValue())) + assertThat(secondUri?.getQueryParameter("scope"), `is`("openid email")) assertThat(secondUri, not(UriMatchers.hasParamWithName("dpop_jkt"))) } From dfe218ce3cb1f7f7ac56c68a57355454d6ced953 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 17 Feb 2026 10:39:22 +0530 Subject: [PATCH 4/4] Addressed the review comments for Dpop instance being saved for configuration changes --- V4_MIGRATION_GUIDE.md | 53 ++++++++---- .../provider/AuthenticationActivity.kt | 2 +- .../auth0/android/provider/OAuthManager.kt | 14 +++- .../android/provider/OAuthManagerState.kt | 9 +- .../auth0/android/provider/WebAuthProvider.kt | 5 +- .../android/provider/OAuthManagerStateTest.kt | 84 +++++++++++++++++++ .../android/provider/WebAuthProviderTest.kt | 13 --- 7 files changed, 140 insertions(+), 40 deletions(-) diff --git a/V4_MIGRATION_GUIDE.md b/V4_MIGRATION_GUIDE.md index 03dc7741..28570c66 100644 --- a/V4_MIGRATION_GUIDE.md +++ b/V4_MIGRATION_GUIDE.md @@ -2,7 +2,9 @@ ## Overview -v4 of the Auth0 Android SDK includes significant build toolchain updates to support the latest Android development environment. This guide documents the changes required when migrating from v3 to v4. +v4 of the Auth0 Android SDK includes significant build toolchain updates to support the latest +Android development environment. This guide documents the changes required when migrating from v3 to +v4. ## Requirements Changes @@ -50,7 +52,8 @@ buildscript { ### Kotlin Version -v4 uses **Kotlin 2.0.21**. If you're using Kotlin in your project, you may need to update your Kotlin version to ensure compatibility. +v4 uses **Kotlin 2.0.21**. If you're using Kotlin in your project, you may need to update your +Kotlin version to ensure compatibility. ```groovy buildscript { @@ -62,14 +65,20 @@ buildscript { ### Classes Removed -- The `com.auth0.android.provider.PasskeyAuthProvider` class has been removed. Use the APIs from the [AuthenticationAPIClient](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt) class for passkey operations: - - [passkeyChallenge()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L366-L387) - Request a challenge to initiate passkey login flow - - [signinWithPasskey()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L235-L253) - Sign in a user using passkeys - - [signupWithPasskey()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L319-L344) - Sign up a user and returns a challenge for key generation +- The `com.auth0.android.provider.PasskeyAuthProvider` class has been removed. Use the APIs from + the [AuthenticationAPIClient](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt) + class for passkey operations: + - [passkeyChallenge()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L366-L387) - + Request a challenge to initiate passkey login flow + - [signinWithPasskey()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L235-L253) - + Sign in a user using passkeys + - [signupWithPasskey()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L319-L344) - + Sign up a user and returns a challenge for key generation ### DPoP Configuration Moved to Builder -The `useDPoP()` method has been moved from the `WebAuthProvider` object to the login `Builder` class. This change allows DPoP to be configured per-request instead of globally. +The `useDPoP(context: Context)` method has been moved from the `WebAuthProvider` object to the login +`Builder` class. This change allows DPoP to be configured per-request instead of globally. **v3 (global configuration — no longer supported):** @@ -87,21 +96,28 @@ WebAuthProvider // ✅ Use this instead WebAuthProvider .login(account) - .useDPoP(context) + .useDPoP(context) .start(context, callback) ``` -This change ensures that DPoP configuration is scoped to individual login requests rather than persisting across the entire application lifecycle. +This change ensures that DPoP configuration is scoped to individual login requests rather than +persisting across the entire application lifecycle. ## Dependency Changes ### ⚠️ Gson 2.8.9 → 2.11.0 (Transitive Dependency) -v4 updates the internal Gson dependency from **2.8.9** to **2.11.0**. While the SDK does not expose Gson types in its public API, Gson is included as a transitive runtime dependency. If your app also uses Gson, be aware of the following changes introduced in Gson 2.10+: +v4 updates the internal Gson dependency from **2.8.9** to **2.11.0**. While the SDK does not expose +Gson types in its public API, Gson is included as a transitive runtime dependency. If your app also +uses Gson, be aware of the following changes introduced in Gson 2.10+: -- **`TypeToken` with unresolved type variables is rejected at runtime.** Code like `object : TypeToken>() {}` (where `T` is a generic parameter) will throw `IllegalArgumentException`. Use Kotlin `reified` type parameters or pass concrete types instead. -- **Strict type coercion is enforced.** Gson no longer silently coerces JSON objects or arrays to `String`. If your code relies on this behavior, you will see `JsonSyntaxException`. -- **Built-in ProGuard/R8 rules are included.** Gson 2.11.0 ships its own keep rules, so you may be able to remove custom Gson ProGuard rules from your project. +- **`TypeToken` with unresolved type variables is rejected at runtime.** Code like + `object : TypeToken>() {}` (where `T` is a generic parameter) will throw + `IllegalArgumentException`. Use Kotlin `reified` type parameters or pass concrete types instead. +- **Strict type coercion is enforced.** Gson no longer silently coerces JSON objects or arrays to + `String`. If your code relies on this behavior, you will see `JsonSyntaxException`. +- **Built-in ProGuard/R8 rules are included.** Gson 2.11.0 ships its own keep rules, so you may be + able to remove custom Gson ProGuard rules from your project. If you need to pin Gson to an older version, you can use Gradle's `resolutionStrategy`: @@ -120,11 +136,14 @@ implementation('com.auth0.android:auth0:') { implementation 'com.google.code.gson:gson:2.8.9' // your preferred version ``` -> **Note:** Pinning or excluding is not recommended long-term, as the SDK has been tested and validated against Gson 2.11.0. +> **Note:** Pinning or excluding is not recommended long-term, as the SDK has been tested and +> validated against Gson 2.11.0. ### DefaultClient.Builder -v4 introduces a `DefaultClient.Builder` for configuring the HTTP client. This replaces the constructor-based approach with a more flexible builder pattern that supports additional options such as write/call timeouts, custom interceptors, and custom loggers. +v4 introduces a `DefaultClient.Builder` for configuring the HTTP client. This replaces the +constructor-based approach with a more flexible builder pattern that supports additional options +such as write/call timeouts, custom interceptors, and custom loggers. **v3 (constructor-based — deprecated):** @@ -149,7 +168,9 @@ val client = DefaultClient.Builder() .build() ``` -The legacy constructor is deprecated but **not removed** — existing code will continue to compile and run. Your IDE will show a deprecation warning with a suggested `ReplaceWith` quick-fix to migrate to the Builder. +The legacy constructor is deprecated but **not removed** — existing code will continue to compile +and run. Your IDE will show a deprecation warning with a suggested `ReplaceWith` quick-fix to +migrate to the Builder. ## Getting Help diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt index 5f3c3f4e..b0f413cc 100644 --- a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt +++ b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt @@ -40,7 +40,7 @@ public open class AuthenticationActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState != null) { - WebAuthProvider.onRestoreInstanceState(savedInstanceState) + WebAuthProvider.onRestoreInstanceState(savedInstanceState, this) intentLaunched = savedInstanceState.getBoolean(EXTRA_INTENT_LAUNCHED, false) } } diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt index acc2b286..b09366e6 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -211,7 +211,8 @@ internal class OAuthManager( auth0 = account, idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, - customAuthorizeUrl = this.customAuthorizeUrl + customAuthorizeUrl = this.customAuthorizeUrl, + dPoPEnabled = dPoP != null ) } @@ -387,14 +388,21 @@ internal class OAuthManager( internal fun OAuthManager.Companion.fromState( state: OAuthManagerState, - callback: Callback + callback: Callback, + context: Context ): OAuthManager { + // Enable DPoP on the restored PKCE's AuthenticationAPIClient so that + // the token exchange request includes the DPoP proof after process restore. + if (state.dPoPEnabled && state.pkce != null) { + state.pkce.apiClient.useDPoP(context) + } return OAuthManager( account = state.auth0, ctOptions = state.ctOptions, parameters = state.parameters, callback = callback, - customAuthorizeUrl = state.customAuthorizeUrl + customAuthorizeUrl = state.customAuthorizeUrl, + dPoP = if (state.dPoPEnabled ) DPoP(context) else null ).apply { setHeaders( state.headers diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt index ab677af6..06d0c6b0 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt @@ -6,7 +6,6 @@ import android.util.Base64 import androidx.core.os.ParcelCompat import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationAPIClient -import com.auth0.android.dpop.DPoP import com.auth0.android.request.internal.GsonProvider import com.google.gson.Gson @@ -20,7 +19,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoP: DPoP? = null + val dPoPEnabled: Boolean = false ) { private class OAuthManagerJson( @@ -37,7 +36,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoP: DPoP? = null + val dPoPEnabled: Boolean ) fun serializeToJson( @@ -62,7 +61,7 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, customAuthorizeUrl = this.customAuthorizeUrl, - dPoP = this.dPoP + dPoPEnabled = this.dPoPEnabled ) return gson.toJson(json) } finally { @@ -112,7 +111,7 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = oauthManagerJson.idTokenVerificationIssuer, idTokenVerificationLeeway = oauthManagerJson.idTokenVerificationLeeway, customAuthorizeUrl = oauthManagerJson.customAuthorizeUrl, - dPoP = oauthManagerJson.dPoP + dPoPEnabled = oauthManagerJson.dPoPEnabled ) } finally { parcel.recycle() diff --git a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt index 307db3dc..75832726 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -112,7 +112,7 @@ public object WebAuthProvider { } } - internal fun onRestoreInstanceState(bundle: Bundle) { + internal fun onRestoreInstanceState(bundle: Bundle, context: Context) { if (managerInstance == null) { val stateJson = bundle.getString(KEY_BUNDLE_OAUTH_MANAGER_STATE).orEmpty() if (stateJson.isNotBlank()) { @@ -131,7 +131,8 @@ public object WebAuthProvider { callback.onFailure(error) } } - } + }, + context ) } } diff --git a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt index 2cf2ec8e..2a32f745 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt @@ -44,4 +44,88 @@ internal class OAuthManagerStateTest { Assert.assertEquals(1, deserializedState.idTokenVerificationLeeway) Assert.assertEquals("issuer", deserializedState.idTokenVerificationIssuer) } + + @Test + fun `serialize should persist dPoPEnabled flag as true`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = mapOf("param1" to "value1"), + headers = mapOf("header1" to "value1"), + requestCode = 1, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", mapOf("header1" to "value1")), + idTokenVerificationLeeway = 1, + idTokenVerificationIssuer = "issuer", + dPoPEnabled = true + ) + + val json = state.serializeToJson() + + Assert.assertTrue(json.isNotBlank()) + Assert.assertTrue(json.contains("\"dPoPEnabled\":true")) + + val deserializedState = OAuthManagerState.deserializeState(json) + + Assert.assertTrue(deserializedState.dPoPEnabled) + } + + @Test + fun `serialize should persist dPoPEnabled flag as false by default`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = mapOf("param1" to "value1"), + headers = mapOf("header1" to "value1"), + requestCode = 1, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", mapOf("header1" to "value1")), + idTokenVerificationLeeway = 1, + idTokenVerificationIssuer = "issuer" + ) + + val json = state.serializeToJson() + + val deserializedState = OAuthManagerState.deserializeState(json) + + Assert.assertFalse(deserializedState.dPoPEnabled) + } + + @Test + fun `deserialize should default dPoPEnabled to false when field is missing from JSON`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null + ) + + val json = state.serializeToJson() + // Remove the dPoPEnabled field to simulate legacy JSON + val legacyJson = json.replace(",\"dPoPEnabled\":false", "") + + val deserializedState = OAuthManagerState.deserializeState(legacyJson) + + Assert.assertFalse(deserializedState.dPoPEnabled) + } } diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt index 550f2572..513d1363 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -2851,19 +2851,6 @@ public class WebAuthProviderTest { assertThat(uri, UriMatchers.hasParamWithName("dpop_jkt")) } - @Test - public fun shouldNotAffectLogoutWhenDPoPIsEnabled() { - logout(account) - .start(activity, voidCallback) - - verify(activity).startActivity(intentCaptor.capture()) - val uri = - intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) - assertThat(uri, `is`(notNullValue())) - // Logout should not have DPoP parameters - assertThat(uri, not(UriMatchers.hasParamWithName("dpop_jkt"))) - } - @Test public fun shouldHandleDPoPKeyGenerationFailureGracefully() { `when`(mockKeyStore.hasKeyPair()).thenReturn(false)