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..242a1733 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -29,6 +29,7 @@ internal class OAuthManager( @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val dPoP: DPoP? = null ) : ResumableManager() { + private val parameters: MutableMap private val headers: MutableMap private val ctOptions: CustomTabsOptions @@ -211,7 +212,8 @@ internal class OAuthManager( auth0 = account, idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, - customAuthorizeUrl = this.customAuthorizeUrl + customAuthorizeUrl = this.customAuthorizeUrl, + dPoPEnabled = dPoP != null ) } @@ -387,14 +389,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..9f10bb8e 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 = false ) 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 6d647a43..a668e070 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -142,7 +142,7 @@ public object WebAuthProvider : SenderConstraining { } } - internal fun onRestoreInstanceState(bundle: Bundle) { + internal fun onRestoreInstanceState(bundle: Bundle, context: Context) { if (managerInstance == null) { val oauthStateJson = bundle.getString(KEY_BUNDLE_OAUTH_MANAGER_STATE).orEmpty() val parStateJson = bundle.getString(KEY_BUNDLE_PAR_MANAGER_STATE).orEmpty() @@ -162,7 +162,8 @@ public object WebAuthProvider : SenderConstraining { callback.onFailure(error) } } - } + }, + context ) } else if (parStateJson.isNotBlank()) { val state = PARCodeManagerState.deserializeState(parStateJson) 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 e4ac8238..21ac3965 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt @@ -1,8 +1,16 @@ package com.auth0.android.provider +import android.content.Context import android.graphics.Color import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.result.Credentials import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.core.Is.`is` import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith @@ -44,4 +52,138 @@ 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) + } + + @Test + fun `fromState should re-enable DPoP on the restored PKCE's API client when dPoPEnabled is true`() { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + val auth0 = Auth0.getInstance("clientId", "domain") + val apiClient = AuthenticationAPIClient(auth0) + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder().build(), + pkce = PKCE(apiClient, "codeVerifier", "redirectUri", "codeChallenge", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null, + dPoPEnabled = true + ) + val callback = mock>() + + OAuthManager.fromState(state, callback, context) + + // This is the actual regression guard: the token exchange after process death only + // includes the DPoP proof because fromState re-enables DPoP on the restored API client. + assertThat(apiClient.isDPoPEnabled, `is`(true)) + } + + @Test + fun `fromState should not enable DPoP on the restored PKCE's API client when dPoPEnabled is false`() { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + val auth0 = Auth0.getInstance("clientId", "domain") + val apiClient = AuthenticationAPIClient(auth0) + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder().build(), + pkce = PKCE(apiClient, "codeVerifier", "redirectUri", "codeChallenge", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null, + dPoPEnabled = false + ) + val callback = mock>() + + OAuthManager.fromState(state, callback, context) + + assertThat(apiClient.isDPoPEnabled, `is`(false)) + } } 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 e033aa8d..dd195dbf 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Bundle import android.os.Parcelable import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.matcher.UriMatchers @@ -2958,6 +2959,31 @@ public class WebAuthProviderTest { mockAPI.shutdown() } + @Test + public fun shouldReEnableDPoPOnOAuthManagerAfterProcessDeathRestore() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.useDPoP(mockContext) + .login(account) + .start(activity, callback) + + val bundle = Bundle() + WebAuthProvider.onSaveInstanceState(bundle) + + // Simulate the host process being killed and recreated: the manager instance is gone, + // and the activity is recreated with the saved state. + WebAuthProvider.resetManagerInstance() + WebAuthProvider.onRestoreInstanceState(bundle, activity) + + val restoredManager = WebAuthProvider.managerInstance as OAuthManager + // This asserts the save/restore wiring reconstructs a DPoP-enabled manager. The actual + // regression guard — that DPoP is re-enabled on the restored PKCE's API client so the + // token exchange carries the proof — lives in OAuthManagerStateTest.fromState tests, + // since OAuthManager.pkce is private and not reachable here without reflection. + assertThat(restoredManager.dPoP, `is`(notNullValue())) + } + //** ** ** ** ** ** **// //** ** ** ** ** ** **// //** Helpers Functions**//