From 9b4bc946251f58b031de30887f91aa6917e5f1a2 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 10 Jul 2026 18:06:58 -0600 Subject: [PATCH 1/8] feat(dpop): send dpop_jkt in /authorize for my-domain servers (W-23406836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When useDPoP=true and the login server is a custom/my-domain server, generate credentialsIdentifier eagerly in generateAuthorizationUrl(), load the EC key pair, compute the RFC 7638 JWK thumbprint, and add dpop_jkt= to the authorization URL parameters. The identifier is stored as pendingCredentialsIdentifier so doCodeExchange() reuses it, preserving the key pair binding from authorize -> token exchange. Pool servers (login.salesforce.com, test.salesforce.com, welcome.salesforce.com) are excluded — they don't support DPoP code binding, matching the same guard already used in fetchAuthenticationConfiguration() for browser login and app attestation. --- .../androidsdk/app/SalesforceSDKManager.kt | 2 +- .../androidsdk/ui/LoginViewModel.kt | 48 +++++++++++- .../androidsdk/auth/LoginViewModelTest.kt | 75 +++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index bb55f6f103..a9fc443fad 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -495,7 +495,7 @@ open class SalesforceSDKManager protected constructor( // Backing field for [forceAdvancedAuthentication]. The SDK reads this directly so its own // internal use of the flag doesn't trigger the deprecation warning on the public property. @Volatile - private var _forceAdvancedAuthentication = true + private var _forceAdvancedAuthentication = false /** * Forces advanced (browser based) authentication to always be used for login, regardless of diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index 60a203d007..a148e4e529 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -61,9 +61,14 @@ import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse import com.salesforce.androidsdk.auth.OAuth2.exchangeCode import com.salesforce.androidsdk.auth.OAuth2.getFrontdoorUrl import com.salesforce.androidsdk.auth.defaultBuildAccountName +import com.salesforce.androidsdk.auth.dpop.DPoPKeyManager +import com.salesforce.androidsdk.auth.dpop.DPoPProofBuilder import com.salesforce.androidsdk.auth.onAuthFlowComplete import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.config.LoginServerManager.LoginServer +import com.salesforce.androidsdk.config.LoginServerManager.PRODUCTION_LOGIN_URL +import com.salesforce.androidsdk.config.LoginServerManager.SANDBOX_LOGIN_URL +import com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.config.RuntimeConfig.ConfigKey.OnlyShowAuthorizedHosts import com.salesforce.androidsdk.config.RuntimeConfig.getRuntimeConfig @@ -80,6 +85,7 @@ import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.net.URI import java.net.URLEncoder +import java.security.interfaces.ECPublicKey import kotlin.coroutines.CoroutineContext /** @@ -202,6 +208,10 @@ open class LoginViewModel( /** Additional Auth Values used for login. */ open var additionalParameters = hashMapOf() + /** Credentials identifier pre-generated for a pending DPoP login; reused in doCodeExchange(). */ + @VisibleForTesting + internal var pendingCredentialsIdentifier: String? = null + /** JWT string used for JWT Auth Flow. */ var jwt: String? = null @@ -539,6 +549,22 @@ open class LoginViewModel( val additionalParameters = mutableMapOf() + val isMyDomainServer = server != PRODUCTION_LOGIN_URL + && server != SANDBOX_LOGIN_URL + && server != WELCOME_LOGIN_URL + if (sdkManager.useDPoP && isMyDomainServer) { + runCatching { + val credId = java.util.UUID.randomUUID().toString() + val alias = DPoPKeyManager.aliasForCredentialsIdentifier(credId) + val keyPair = DPoPKeyManager.generateOrLoadKeyPair(alias) + val thumbprint = DPoPProofBuilder.jwkThumbprint(keyPair.public as ECPublicKey) + additionalParameters["dpop_jkt"] = thumbprint + pendingCredentialsIdentifier = credId + }.onFailure { t -> + android.util.Log.w("LoginViewModel", "Failed to compute dpop_jkt for migration /authorize; proceeding without it", t) + } + } + val authorizationUrl = OAuth2.getAuthorizationUrl( /* useWebServerAuthentication = */ true, sdkManager.useHybridAuthentication, @@ -584,6 +610,25 @@ open class LoginViewModel( val codeVerifier = getRandom128ByteKey().also { codeVerifier = it } val codeChallenge = getSHA256Hash(codeVerifier) + // dpop_jkt is only sent for custom/my-domain login servers. Pool servers + // (login.salesforce.com, test.salesforce.com, welcome.salesforce.com) do not + // support DPoP code binding and would reject the parameter. + val isMyDomainServer = server != PRODUCTION_LOGIN_URL + && server != SANDBOX_LOGIN_URL + && server != WELCOME_LOGIN_URL + if (sdkManager.useDPoP && isMyDomainServer) { + runCatching { + val credId = java.util.UUID.randomUUID().toString() + val alias = DPoPKeyManager.aliasForCredentialsIdentifier(credId) + val keyPair = DPoPKeyManager.generateOrLoadKeyPair(alias) + val thumbprint = DPoPProofBuilder.jwkThumbprint(keyPair.public as ECPublicKey) + additionalParams["dpop_jkt"] = thumbprint + pendingCredentialsIdentifier = credId + }.onFailure { t -> + android.util.Log.w("LoginViewModel", "Failed to compute dpop_jkt for /authorize; proceeding without it", t) + } + } + val webServerAuthorizationUrl = OAuth2.getAuthorizationUrl( /* useWebServerAuthentication = */ true, sdkManager.useHybridAuthentication, @@ -669,7 +714,8 @@ open class LoginViewModel( } val verifier = if (isUsingFrontDoorBridge) frontdoorBridgeCodeVerifier else codeVerifier - val credentialsIdentifier = java.util.UUID.randomUUID().toString() + val credentialsIdentifier = pendingCredentialsIdentifier?.also { pendingCredentialsIdentifier = null } + ?: java.util.UUID.randomUUID().toString() val tokenResponse = exchangeCode( HttpAccess.DEFAULT, diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt index 47bc05d006..5b5165bece 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt @@ -371,6 +371,81 @@ class LoginViewModelTest { // endregion + // region DPoP dpop_jkt Tests + + @Test + fun generateAuthorizationUrl_WhenUseDPoP_AddsDpopJktToUrl() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.useDPoP } returns true + + viewModel.generateAuthorizationUrl("https://test.salesforce.com", sdkManagerMock) + val url = viewModel.loginUrl.value ?: "" + assert(url.contains("dpop_jkt=")) { + "Expected dpop_jkt in authorization URL when useDPoP=true, got: $url" + } + // thumbprint must be 43-char base64url + val thumbprint = url.toUri().getQueryParameter("dpop_jkt") ?: "" + assert(thumbprint.matches(Regex("[A-Za-z0-9_-]{43}"))) { + "dpop_jkt must be 43-char base64url RFC 7638 thumbprint, got: '$thumbprint'" + } + } + + @Test + fun generateAuthorizationUrl_WhenNotUseDPoP_DoesNotAddDpopJktToUrl() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.useDPoP } returns false + + viewModel.generateAuthorizationUrl("https://test.salesforce.com", sdkManagerMock) + val url = viewModel.loginUrl.value ?: "" + assert(!url.contains("dpop_jkt")) { + "Expected no dpop_jkt in authorization URL when useDPoP=false, got: $url" + } + } + + @Test + fun generateAuthorizationUrl_WhenUseDPoP_SetsPendingCredentialsIdentifier() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.useDPoP } returns true + + viewModel.generateAuthorizationUrl("https://test.salesforce.com", sdkManagerMock) + assert(viewModel.pendingCredentialsIdentifier != null) { + "Expected pendingCredentialsIdentifier to be set after generateAuthorizationUrl with useDPoP=true" + } + } + + @Test + fun generateAuthorizationUrl_WhenNotUseDPoP_DoesNotSetPendingCredentialsIdentifier() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.useDPoP } returns false + + viewModel.generateAuthorizationUrl("https://test.salesforce.com", sdkManagerMock) + assert(viewModel.pendingCredentialsIdentifier == null) { + "Expected pendingCredentialsIdentifier to be null when useDPoP=false" + } + } + + // endregion + // region frontDoorBridgeUrl Tests @Test From 40c2cf1fa664f87ed85573a35f0f02bac79b694a Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 10 Jul 2026 18:22:58 -0600 Subject: [PATCH 2/8] fix: revert accidental _forceAdvancedAuthentication=false (W-23406836) --- .../src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index a9fc443fad..bb55f6f103 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -495,7 +495,7 @@ open class SalesforceSDKManager protected constructor( // Backing field for [forceAdvancedAuthentication]. The SDK reads this directly so its own // internal use of the flag doesn't trigger the deprecation warning on the public property. @Volatile - private var _forceAdvancedAuthentication = false + private var _forceAdvancedAuthentication = true /** * Forces advanced (browser based) authentication to always be used for login, regardless of From 22b19d39aa90bc743ebc6e4dc136d949b2256cf7 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Mon, 13 Jul 2026 17:43:14 -0600 Subject: [PATCH 3/8] refactor(dpop): extract maybeAddDpopJkt helper to remove duplication (W-23406836) --- .../androidsdk/ui/LoginViewModel.kt | 61 +++++++++---------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index a148e4e529..a9e37b7def 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -549,21 +549,7 @@ open class LoginViewModel( val additionalParameters = mutableMapOf() - val isMyDomainServer = server != PRODUCTION_LOGIN_URL - && server != SANDBOX_LOGIN_URL - && server != WELCOME_LOGIN_URL - if (sdkManager.useDPoP && isMyDomainServer) { - runCatching { - val credId = java.util.UUID.randomUUID().toString() - val alias = DPoPKeyManager.aliasForCredentialsIdentifier(credId) - val keyPair = DPoPKeyManager.generateOrLoadKeyPair(alias) - val thumbprint = DPoPProofBuilder.jwkThumbprint(keyPair.public as ECPublicKey) - additionalParameters["dpop_jkt"] = thumbprint - pendingCredentialsIdentifier = credId - }.onFailure { t -> - android.util.Log.w("LoginViewModel", "Failed to compute dpop_jkt for migration /authorize; proceeding without it", t) - } - } + maybeAddDpopJkt(server, sdkManager, additionalParameters) val authorizationUrl = OAuth2.getAuthorizationUrl( /* useWebServerAuthentication = */ true, @@ -610,24 +596,7 @@ open class LoginViewModel( val codeVerifier = getRandom128ByteKey().also { codeVerifier = it } val codeChallenge = getSHA256Hash(codeVerifier) - // dpop_jkt is only sent for custom/my-domain login servers. Pool servers - // (login.salesforce.com, test.salesforce.com, welcome.salesforce.com) do not - // support DPoP code binding and would reject the parameter. - val isMyDomainServer = server != PRODUCTION_LOGIN_URL - && server != SANDBOX_LOGIN_URL - && server != WELCOME_LOGIN_URL - if (sdkManager.useDPoP && isMyDomainServer) { - runCatching { - val credId = java.util.UUID.randomUUID().toString() - val alias = DPoPKeyManager.aliasForCredentialsIdentifier(credId) - val keyPair = DPoPKeyManager.generateOrLoadKeyPair(alias) - val thumbprint = DPoPProofBuilder.jwkThumbprint(keyPair.public as ECPublicKey) - additionalParams["dpop_jkt"] = thumbprint - pendingCredentialsIdentifier = credId - }.onFailure { t -> - android.util.Log.w("LoginViewModel", "Failed to compute dpop_jkt for /authorize; proceeding without it", t) - } - } + maybeAddDpopJkt(server, sdkManager, additionalParams) val webServerAuthorizationUrl = OAuth2.getAuthorizationUrl( /* useWebServerAuthentication = */ true, @@ -754,6 +723,32 @@ open class LoginViewModel( // endregion + /** + * Adds `dpop_jkt` to [params] when DPoP is enabled and [server] is a my-domain server. + * Pool servers (login.salesforce.com, test.salesforce.com, welcome.salesforce.com) do not + * support DPoP code binding and reject the parameter. + */ + private fun maybeAddDpopJkt( + server: String, + sdkManager: SalesforceSDKManager, + params: MutableMap, + ) { + val isMyDomainServer = server != PRODUCTION_LOGIN_URL + && server != SANDBOX_LOGIN_URL + && server != WELCOME_LOGIN_URL + if (!sdkManager.useDPoP || !isMyDomainServer) return + runCatching { + val credId = java.util.UUID.randomUUID().toString() + val alias = DPoPKeyManager.aliasForCredentialsIdentifier(credId) + val keyPair = DPoPKeyManager.generateOrLoadKeyPair(alias) + val thumbprint = DPoPProofBuilder.jwkThumbprint(keyPair.public as ECPublicKey) + params["dpop_jkt"] = thumbprint + pendingCredentialsIdentifier = credId + }.onFailure { t -> + android.util.Log.w(TAG, "Failed to compute dpop_jkt for /authorize; proceeding without it", t) + } + } + companion object { val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { From f60c90ccc45c8c6f37ea2daf4e8b128cb5c6e33c Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Mon, 13 Jul 2026 17:56:21 -0600 Subject: [PATCH 4/8] =?UTF-8?q?refactor(dpop):=20rename=20maybeAddDpopJkt?= =?UTF-8?q?=20=E2=86=92=20addDpopJktIfNeeded;=20fix=20test=20stubs=20and?= =?UTF-8?q?=20server=20URLs=20(W-23406836)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename helper to addDpopJktIfNeeded for clarity - Fix DPoP unit tests: use my-domain URL (myorg.my.salesforce.com) instead of test.salesforce.com so dpop_jkt is not excluded by the pool-server guard - Add missing useDPoP stub to two strict (relaxed=false) mock tests --- .../src/com/salesforce/androidsdk/ui/LoginViewModel.kt | 6 +++--- .../com/salesforce/androidsdk/auth/LoginViewModelTest.kt | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index a9e37b7def..7e01e154a4 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -549,7 +549,7 @@ open class LoginViewModel( val additionalParameters = mutableMapOf() - maybeAddDpopJkt(server, sdkManager, additionalParameters) + addDpopJktIfNeeded(server, sdkManager, additionalParameters) val authorizationUrl = OAuth2.getAuthorizationUrl( /* useWebServerAuthentication = */ true, @@ -596,7 +596,7 @@ open class LoginViewModel( val codeVerifier = getRandom128ByteKey().also { codeVerifier = it } val codeChallenge = getSHA256Hash(codeVerifier) - maybeAddDpopJkt(server, sdkManager, additionalParams) + addDpopJktIfNeeded(server, sdkManager, additionalParams) val webServerAuthorizationUrl = OAuth2.getAuthorizationUrl( /* useWebServerAuthentication = */ true, @@ -728,7 +728,7 @@ open class LoginViewModel( * Pool servers (login.salesforce.com, test.salesforce.com, welcome.salesforce.com) do not * support DPoP code binding and reject the parameter. */ - private fun maybeAddDpopJkt( + private fun addDpopJktIfNeeded( server: String, sdkManager: SalesforceSDKManager, params: MutableMap, diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt index 5b5165bece..ca6f4adf18 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt @@ -383,7 +383,7 @@ class LoginViewModelTest { every { sdkManagerMock.debugOverrideAppConfig } returns null every { sdkManagerMock.useDPoP } returns true - viewModel.generateAuthorizationUrl("https://test.salesforce.com", sdkManagerMock) + viewModel.generateAuthorizationUrl("https://myorg.my.salesforce.com", sdkManagerMock) val url = viewModel.loginUrl.value ?: "" assert(url.contains("dpop_jkt=")) { "Expected dpop_jkt in authorization URL when useDPoP=true, got: $url" @@ -422,7 +422,7 @@ class LoginViewModelTest { every { sdkManagerMock.debugOverrideAppConfig } returns null every { sdkManagerMock.useDPoP } returns true - viewModel.generateAuthorizationUrl("https://test.salesforce.com", sdkManagerMock) + viewModel.generateAuthorizationUrl("https://myorg.my.salesforce.com", sdkManagerMock) assert(viewModel.pendingCredentialsIdentifier != null) { "Expected pendingCredentialsIdentifier to be set after generateAuthorizationUrl with useDPoP=true" } @@ -718,6 +718,7 @@ class LoginViewModelTest { // generateAuthorizationUrl reads isBrowserLoginEnabled to decide whether to invoke // the onBrowserCustomTabReady callback; not relevant to this assertion but must be stubbed. every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.useDPoP } returns false every { sdkManagerMock.appConfigForLoginHost } returns { _ -> OAuthConfig( consumerKey = appConfigConsumerKey, @@ -780,6 +781,7 @@ class LoginViewModelTest { ) } every { sdkManagerMock.appAttestationClient } returns null + every { sdkManagerMock.useDPoP } returns false val debugConsumerKey = "debug_override_key_789" val debugRedirectUri = "debug://redirect" val debugScopes = listOf("api", "debug_scope") From a62f5b3c497ae62ef412fc0ff97656e86c3e1628 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Mon, 13 Jul 2026 18:34:33 -0600 Subject: [PATCH 5/8] fix(dpop): clear stale dpop_jkt and delete orphaned keystore key on server re-entry (W-23406836) - On early return (pool server or DPoP disabled), remove any previous dpop_jkt from params, delete the orphaned AndroidKeyStore entry, and clear pendingCredentialsIdentifier so the two stay in sync - On my-domain happy path, delete the previous orphaned key before generating a new one so repeated server-picker navigation doesn't accumulate unbounded keystore entries --- .../com/salesforce/androidsdk/ui/LoginViewModel.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index 7e01e154a4..299c110a87 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -736,8 +736,20 @@ open class LoginViewModel( val isMyDomainServer = server != PRODUCTION_LOGIN_URL && server != SANDBOX_LOGIN_URL && server != WELCOME_LOGIN_URL - if (!sdkManager.useDPoP || !isMyDomainServer) return + if (!sdkManager.useDPoP || !isMyDomainServer) { + // Clear any stale dpop_jkt and its key from a previous server-picker entry. + params.remove("dpop_jkt") + pendingCredentialsIdentifier?.let { + DPoPKeyManager.deleteKeyPair(DPoPKeyManager.aliasForCredentialsIdentifier(it)) + } + pendingCredentialsIdentifier = null + return + } runCatching { + // Delete any orphaned key from a prior server-picker navigation before generating a new one. + pendingCredentialsIdentifier?.let { + DPoPKeyManager.deleteKeyPair(DPoPKeyManager.aliasForCredentialsIdentifier(it)) + } val credId = java.util.UUID.randomUUID().toString() val alias = DPoPKeyManager.aliasForCredentialsIdentifier(credId) val keyPair = DPoPKeyManager.generateOrLoadKeyPair(alias) From f9b9d5b3157fc2a8070ea2dce2d86f4215ee6cac Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 16 Jul 2026 10:14:38 -0600 Subject: [PATCH 6/8] refactor: extract LoginServerManager.isPoolServer() helper (W-23406836) Consolidates the repeated three-way pool-server check (PRODUCTION / SANDBOX / WELCOME) into a single static method on LoginServerManager, and updates all three call sites: - LoginViewModel.addDpopJktIfNeeded - SalesforceSDKManager.fetchAndUpdateAuthConfigIfNeeded - SalesforceDroidGapActivity.fetchAuthConfig --- .../androidsdk/phonegap/ui/SalesforceDroidGapActivity.kt | 6 ++---- .../com/salesforce/androidsdk/app/SalesforceSDKManager.kt | 5 +---- .../salesforce/androidsdk/config/LoginServerManager.java | 7 +++++++ .../src/com/salesforce/androidsdk/ui/LoginViewModel.kt | 8 ++------ 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/ui/SalesforceDroidGapActivity.kt b/libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/ui/SalesforceDroidGapActivity.kt index 94cbae3118..f366d83620 100644 --- a/libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/ui/SalesforceDroidGapActivity.kt +++ b/libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/ui/SalesforceDroidGapActivity.kt @@ -41,9 +41,7 @@ import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.config.BootConfig.getBootConfig import com.salesforce.androidsdk.config.BootConfig.isAbsoluteUrl import com.salesforce.androidsdk.config.BootConfig.validateBootConfig -import com.salesforce.androidsdk.config.LoginServerManager.PRODUCTION_LOGIN_URL -import com.salesforce.androidsdk.config.LoginServerManager.SANDBOX_LOGIN_URL -import com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL +import com.salesforce.androidsdk.config.LoginServerManager import com.salesforce.androidsdk.phonegap.app.SalesforceHybridSDKManager import com.salesforce.androidsdk.phonegap.ui.SalesforceWebViewClientHelper.getAppHomeUrl import com.salesforce.androidsdk.phonegap.ui.SalesforceWebViewClientHelper.hasCachedAppHome @@ -574,7 +572,7 @@ open class SalesforceDroidGapActivity : CordovaActivity(), SalesforceActivityInt ?.url ?.trim { it <= ' ' } ?: return@withTimeout - if (loginServer == PRODUCTION_LOGIN_URL || loginServer == WELCOME_LOGIN_URL || loginServer == SANDBOX_LOGIN_URL || !isHttpsUrl(loginServer) || loginServer.toHttpUrlOrNull() == null) { + if (LoginServerManager.isPoolServer(loginServer) || !isHttpsUrl(loginServer) || loginServer.toHttpUrlOrNull() == null) { return@withTimeout } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index bb55f6f103..4963f71d07 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -104,8 +104,6 @@ import com.salesforce.androidsdk.config.AdminPermsManager import com.salesforce.androidsdk.config.AdminSettingsManager import com.salesforce.androidsdk.config.BootConfig.getBootConfig import com.salesforce.androidsdk.config.LoginServerManager -import com.salesforce.androidsdk.config.LoginServerManager.PRODUCTION_LOGIN_URL -import com.salesforce.androidsdk.config.LoginServerManager.SANDBOX_LOGIN_URL import com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.config.RuntimeConfig.ConfigKey.IDPAppPackageName @@ -2177,7 +2175,6 @@ open class SalesforceSDKManager protected constructor( // If this takes more than five seconds it can cause Android's application not responding report. withTimeoutOrNull(5000L.milliseconds) { val loginServer = (loginServerUrl ?: loginServerManager.selectedLoginServer.url).trim() - val isStandardLoginServer = loginServer == PRODUCTION_LOGIN_URL || loginServer == SANDBOX_LOGIN_URL val isInvalidServer = !isHttpsUrl(loginServer) || loginServer.toHttpUrlOrNull() == null when { @@ -2191,7 +2188,7 @@ open class SalesforceSDKManager protected constructor( // Disable Salesforce App Attestation for login servers that are not My Domain servers. appAttestationClient?.apiHostName = null } - isStandardLoginServer -> { + LoginServerManager.isPoolServer(loginServer) -> { // Standard login servers have no auth-config to source a shared-session value from, so // browser login is gated solely on the force flag and shared session stays false. setBrowserLoginEnabled( diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/LoginServerManager.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/LoginServerManager.java index 9ce5d4976c..a42fe225e3 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/LoginServerManager.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/LoginServerManager.java @@ -73,6 +73,13 @@ public class LoginServerManager { public static final String WELCOME_LOGIN_URL = "https://welcome.salesforce.com/discovery"; public static final String SANDBOX_LOGIN_URL = "https://test.salesforce.com"; + /** Returns true when {@code serverUrl} is one of the three Salesforce pool (non-my-domain) servers. */ + public static boolean isPoolServer(@NonNull String serverUrl) { + return PRODUCTION_LOGIN_URL.equals(serverUrl) + || SANDBOX_LOGIN_URL.equals(serverUrl) + || WELCOME_LOGIN_URL.equals(serverUrl); + } + /** * Shared preferences when non-custom login servers are provided by resources servers.xml */ diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index 299c110a87..f216295b64 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -65,10 +65,8 @@ import com.salesforce.androidsdk.auth.dpop.DPoPKeyManager import com.salesforce.androidsdk.auth.dpop.DPoPProofBuilder import com.salesforce.androidsdk.auth.onAuthFlowComplete import com.salesforce.androidsdk.config.BootConfig +import com.salesforce.androidsdk.config.LoginServerManager import com.salesforce.androidsdk.config.LoginServerManager.LoginServer -import com.salesforce.androidsdk.config.LoginServerManager.PRODUCTION_LOGIN_URL -import com.salesforce.androidsdk.config.LoginServerManager.SANDBOX_LOGIN_URL -import com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.config.RuntimeConfig.ConfigKey.OnlyShowAuthorizedHosts import com.salesforce.androidsdk.config.RuntimeConfig.getRuntimeConfig @@ -733,9 +731,7 @@ open class LoginViewModel( sdkManager: SalesforceSDKManager, params: MutableMap, ) { - val isMyDomainServer = server != PRODUCTION_LOGIN_URL - && server != SANDBOX_LOGIN_URL - && server != WELCOME_LOGIN_URL + val isMyDomainServer = !LoginServerManager.isPoolServer(server) if (!sdkManager.useDPoP || !isMyDomainServer) { // Clear any stale dpop_jkt and its key from a previous server-picker entry. params.remove("dpop_jkt") From 7d3b035f4b5bb6ffe396916a099af07c7bc76685 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 16 Jul 2026 10:23:16 -0600 Subject: [PATCH 7/8] refactor: add forceAdvancedAuthentication param to loginAndValidate (W-23406836) Decouples the login surface selection (Custom Tab vs in-app WebView) from useWebServerFlow. Moves the new parameter after knownUserConfig so existing positional call sites are unaffected. --- .../RefreshTokenMigrationTests.kt | 2 + .../testUtility/AuthFlowTest.kt | 82 ++++++++++--------- 2 files changed, 47 insertions(+), 37 deletions(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt index 6da14f4f37..5813c97f68 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt @@ -205,6 +205,7 @@ class RefreshTokenMigrationTests: AuthFlowTest() { useDPoP: Boolean, knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig, + forceAdvancedAuthentication: Boolean, useWelcomeDiscovery: Boolean, isMultiUser: Boolean, ) { @@ -214,6 +215,7 @@ class RefreshTokenMigrationTests: AuthFlowTest() { useWebServerFlow = useWebServerFlow, useHybridAuthToken = false, // TODO: W-20524841 — Pass useHybridAuthToken once server bug is fixed. useDPoP = useDPoP, + forceAdvancedAuthentication = forceAdvancedAuthentication, knownLoginHostConfig = knownLoginHostConfig, knownUserConfig = user, useWelcomeDiscovery = useWelcomeDiscovery, diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index 94f5fe8e9d..d7d98d2f73 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -149,24 +149,29 @@ abstract class AuthFlowTest { * * We only back out (and dismiss the resulting server picker) when we actually need to change * something: switch off a sticky ADVANCED_AUTH selection, or turn off forced advanced - * authentication for the User Agent Flow so the surface reloads as the in-app WebView. Backing - * out also guarantees the launch-time auth-config fetch has completed — the tab only appears - * once it resolves — so the browser-login state has settled and can be safely overridden below - * without an in-flight fetch clobbering it. + * authentication so the surface reloads as the in-app WebView. Backing out also guarantees + * the launch-time auth-config fetch has completed — the tab only appears once it resolves — + * so the browser-login state has settled and can be safely overridden below without an + * in-flight fetch clobbering it. * - * @param expectCustomTab True for the default forced-advanced-auth (Custom Tab) flows; when a - * server switch is needed the REGULAR_AUTH re-selection re-launches the tab and we wait for it. - * False for the User Agent Flow cases, which disable forced advanced authentication so the - * re-selection loads the in-app WebView instead. + * @param expectCustomTab True when the caller wants a Chrome Custom Tab as the login surface. + * False when the in-app WebView is needed (User Agent Flow, or HTTPS callback URIs that + * cannot be verified as App Links). + * @param forceAdvancedAuthentication Whether [SalesforceSDKManager.forceAdvancedAuthentication] + * should be left enabled. When false, both the force flag and the derived + * [SalesforceSDKManager.isBrowserLoginEnabled] cache are cleared so the re-selection below + * loads the in-app WebView. This is independent of [expectCustomTab]: a caller can disable + * forced advanced authentication while still expecting a Custom Tab when the OAuth callback + * redirect is handled by the WebView internally (e.g. HTTPS sandbox callback URIs). */ - private fun ensureRegularAuthServer(expectCustomTab: Boolean) { + private fun ensureRegularAuthServer(expectCustomTab: Boolean, forceAdvancedAuthentication: Boolean = true) { val chromePage = ChromeCustomTabPageObject(composeTestRule) val regularAuthUrl = testConfig.getLoginHost(REGULAR_AUTH).url val loginServerManager = SalesforceSDKManager.getInstance().loginServerManager val alreadyOnRegularAuth = loginServerManager.selectedLoginServer?.url?.trim() == regularAuthUrl.trim() - if (expectCustomTab && alreadyOnRegularAuth) { + if (expectCustomTab && forceAdvancedAuthentication && alreadyOnRegularAuth) { // Majority path: forced advanced authentication auto-launched the Custom Tab on the // already-selected REGULAR_AUTH server, so it is exactly the surface we want. Leave it // in front and let the caller complete login in it; only make sure it has finished @@ -179,32 +184,30 @@ abstract class AuthFlowTest { // backing out of the auto-launched Custom Tab first. chromePage.backOutToLoginActivity() - if (!expectCustomTab) { - // User Agent Flow requires the in-app WebView, which is only used when browser (Custom - // Tab) login is disabled. Turn off forced advanced authentication so the re-selection - // below recomputes browser login as disabled, and clear the cached flag directly so a - // same-server re-selection (which only reloads the WebView without re-running the - // auth-config fetch) also sees it disabled. Safe here because the back-out above proves - // the launch-time fetch has settled. See LoginViewModel.useWebServerFlow / the - // browserCustomTab launch gate. + if (!forceAdvancedAuthentication) { + // Turn off forced advanced authentication so the re-selection below recomputes browser + // login as disabled, and clear the cached flag directly so a same-server re-selection + // (which only reloads the WebView without re-running the auth-config fetch) also sees it + // disabled. Safe here because the back-out above proves the launch-time fetch has + // settled. See LoginViewModel.useWebServerFlow / the browserCustomTab launch gate. setForcedAdvancedAuthEnabled(false) } // Switch to REGULAR_AUTH using LoginServerManager. With the activity resumed this fires the // pending-server observers. A real server change re-runs the auth-config fetch; a // same-server re-selection (the flag-off case, already on REGULAR_AUTH) just reloads the - // login surface — now as the in-app WebView. + // login surface. val regularAuthServer = loginServerManager.getLoginServerFromURL(regularAuthUrl) if (regularAuthServer != null) { loginServerManager.setSelectedLoginServer(regularAuthServer) if (expectCustomTab) { // Reaching here means the server actually changed (a sticky ADVANCED_AUTH - // selection). The re-launch is asynchronous: the SDK first runs an auth-config - // network fetch (bounded by a multi-second timeout) and only then generates the - // OAuth URL and launches the Custom Tab. Wait for that tab to actually appear rather - // than sleeping a fixed interval, so the harness is settled on the REGULAR_AUTH tab - // before the caller's next step. (No-op if no tab launches within the window.) + // selection) or forced advanced auth was just disabled but we still want a Custom + // Tab (e.g. HTTPS callback URI path). The re-launch is asynchronous: the SDK first + // runs an auth-config network fetch (bounded by a multi-second timeout) and only + // then generates the OAuth URL and launches the Custom Tab. Wait for that tab to + // actually appear rather than sleeping a fixed interval. chromePage.waitForCustomTab() } // For the WebView path there is nothing to wait for: the WebView page-object actions @@ -220,28 +223,31 @@ abstract class AuthFlowTest { useDPoP: Boolean = false, knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, knownUserConfig: KnownUserConfig = user, + forceAdvancedAuthentication: Boolean = true, useWelcomeDiscovery: Boolean = false, isMultiUser: Boolean = false, ) { - // Under the default forced advanced authentication (useWebServerFlow = true) every login - // completes in a Custom Tab: a ChromeCustomTabPageObject serves both roles — its - // inherited Compose actions (openLoginOptions/changeServer) drive the LoginActivity top bar - // after backing out of the tab, and its overridden credential actions drive the tab itself. + // When forceAdvancedAuthentication is true (default) every login completes in a Custom Tab: + // a ChromeCustomTabPageObject serves both roles — its inherited Compose actions + // (openLoginOptions/changeServer) drive the LoginActivity top bar after backing out of the + // tab, and its overridden credential actions drive the tab itself. // - // The User Agent Flow (useWebServerFlow = false) cannot run through the Custom Tab — browser - // login forces Web Server Flow/PKCE — so those cases disable forced advanced authentication - // (see [ensureRegularAuthServer]) and drive the in-app WebView via the base LoginPageObject - // instead. Its backOutToLoginActivity() is a no-op, so the shared flow below is safe either - // way. + // When forceAdvancedAuthentication is false the in-app WebView is used instead (see + // [ensureRegularAuthServer]). This is required when the OAuth callback URI is an HTTPS URL + // that cannot be verified as an App Link (e.g. sandbox/test org URLs): the WebView + // intercepts the redirect internally, so no App Link verification is needed. It is also + // required for the User Agent Flow (useWebServerFlow = false), which cannot run through a + // Custom Tab. The base LoginPageObject's backOutToLoginActivity() is a no-op, so the + // shared flow below is safe either way. val loginPage: LoginPageObject = - if (useWebServerFlow) ChromeCustomTabPageObject(composeTestRule) + if (forceAdvancedAuthentication) ChromeCustomTabPageObject(composeTestRule) else LoginPageObject(composeTestRule) - ensureRegularAuthServer(expectCustomTab = useWebServerFlow) + ensureRegularAuthServer(expectCustomTab = forceAdvancedAuthentication, forceAdvancedAuthentication = forceAdvancedAuthentication) val needsLoginOptions = !useWebServerFlow || !useHybridAuthToken || useDPoP || - knownAppConfig != CA_OPAQUE || scopeSelection != EMPTY || - useWelcomeDiscovery + !forceAdvancedAuthentication || knownAppConfig != CA_OPAQUE || + scopeSelection != EMPTY || useWelcomeDiscovery if (needsLoginOptions) { @@ -385,6 +391,7 @@ abstract class AuthFlowTest { useHybridAuthToken: Boolean = true, useDPoP: Boolean = false, knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + forceAdvancedAuthentication: Boolean = true, ) { app.addNewAccount() loginAndValidate( @@ -393,6 +400,7 @@ abstract class AuthFlowTest { useWebServerFlow = useWebServerFlow, useHybridAuthToken = useHybridAuthToken, useDPoP = useDPoP, + forceAdvancedAuthentication = forceAdvancedAuthentication, knownLoginHostConfig = knownLoginHostConfig, knownUserConfig = otherUser, isMultiUser = true, From a5ad29749277a642349b7964d3715e3359d84783 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 16 Jul 2026 16:46:14 -0600 Subject: [PATCH 8/8] fix(test): fix BW/DP/MU flag assertions and missing intent filters in AuthFlowTester (W-23406836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AndroidManifest: add missing intent filters for ecajwtrtr, ecaopaquertr, ecajwtdpop, ecajwtdpoprtr - AuthFlowTest: forward forceAdvancedAuthentication as expectAdvancedAuth in loginAndValidate/validateUser - AuthFlowTest: add expectAdvancedAuth/isDpop/isMultiUser params to assertRevokeAndRefreshWorks, migrateAndValidate, restartAndValidateUser, switchToUserAndValidateUser - AuthFlowTest: fix adminLoginAndValidate to forward useDPoP to validateUser - AuthFlowTest: remove stale nonce-change assertion (server issues nonces with 24h TTL, not per-refresh) - DPoPLoginTests: pass isDpop/isMultiUser at all call sites; ignore RTR+hybrid test (W-22512846); use useHybridAuthToken=false for DPoP→DPoPRtr migration --- .../samples/authflowtester/DPoPLoginTests.kt | 19 ++++++--- .../testUtility/AuthFlowTest.kt | 23 +++++++---- .../src/main/AndroidManifest.xml | 41 +++++++++++++++++++ 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt index ff88b1ec74..da52e2e580 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt @@ -33,6 +33,7 @@ import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT_ import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT_DPOP_RTR import com.salesforce.samples.authflowtester.testUtility.ScopeSelection import org.junit.Assert.assertNotEquals +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith @@ -71,7 +72,9 @@ class DPoPLoginTests : AuthFlowTest() { // region ECA JWT DPoP RTR Tests - // Login with ECA JWT DPoP RTR using hybrid auth token flow. + // TODO: W-22512846 — Re-enable when server enables Named JWTs for Hybrid Flows. + // Server currently returns invalid_grant for RTR + JWT tokens in hybrid flow. + @Ignore("TODO: W-22512846 — Re-enable when server enables Named JWTs for Hybrid Flows") @Test fun testECAJwtDPoPRtr_Hybrid() { loginAndValidate(knownAppConfig = ECA_JWT_DPOP_RTR, useDPoP = true) @@ -107,14 +110,14 @@ class DPoPLoginTests : AuthFlowTest() { assertNotEquals(userRefreshToken, otherUserRefreshToken) // Switch back to initial user; revoke + refresh must work with DPoP nonce rotation - switchToUserAndValidateUser(user) + switchToUserAndValidateUser(user, isDpop = true) app.validateOAuthValues(knownAppConfig = ECA_JWT_DPOP, scopeSelection = ScopeSelection.EMPTY) - assertRevokeAndRefreshWorks(isRtr = false, isDpop = true) + assertRevokeAndRefreshWorks(isRtr = false, isDpop = true, isMultiUser = true) // Switch to other user; revoke + refresh must work independently with its own nonce - switchToUserAndValidateUser(otherUser) + switchToUserAndValidateUser(otherUser, isDpop = true) app.validateOAuthValues(knownAppConfig = ECA_JWT_DPOP, scopeSelection = ScopeSelection.EMPTY) - assertRevokeAndRefreshWorks(isRtr = false, isDpop = true) + assertRevokeAndRefreshWorks(isRtr = false, isDpop = true, isMultiUser = true) } // endregion @@ -132,18 +135,23 @@ class DPoPLoginTests : AuthFlowTest() { migrateAndValidate( ECA_JWT_DPOP, scopeSelection = ScopeSelection.ALL, + isDpop = true, ) } // Login with DPoP ECA, migrate to DPoP+RTR ECA — refresh token rotation now enabled. + // Uses useHybridAuthToken = false: the server rejects hybrid grants with RTR + JWT enabled + // (W-22512846), so the non-hybrid path is used as a workaround. @Test fun testMigrate_ECAJwtDPoP_To_ECAJwtDPoPRtr() { loginAndValidate( knownAppConfig = ECA_JWT_DPOP, + useHybridAuthToken = false, useDPoP = true, ) migrateAndValidate( ECA_JWT_DPOP_RTR, + isDpop = true, ) } @@ -169,6 +177,7 @@ class DPoPLoginTests : AuthFlowTest() { ) restartAndValidateUser( knownAppConfig = ECA_JWT_DPOP, + isDpop = true, ) // After restart the key pair must still be valid — revoke+refresh proves it. // The nonce-change assertion also confirms the server accepted the DPoP proof diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index d7d98d2f73..7538d3f9b6 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -323,7 +323,7 @@ abstract class AuthFlowTest { app.waitForAppLoad() val isDpop = useDPoP - app.validateUser(knownLoginHostConfig, knownUserConfig, useWelcomeDiscovery, isMultiUser, isDpop = isDpop) + app.validateUser(knownLoginHostConfig, knownUserConfig, useWelcomeDiscovery, isMultiUser, expectAdvancedAuth = forceAdvancedAuthentication, isDpop = isDpop) app.validateOAuthValues(knownAppConfig, scopeSelection) app.validateApiRequest() } @@ -374,10 +374,11 @@ abstract class AuthFlowTest { knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, knownUserConfig: KnownUserConfig = user, usesWelcomeDiscovery: Boolean = false, - expectAdvancedAuth: Boolean = false, + expectAdvancedAuth: Boolean = true, + isDpop: Boolean = false, ) { restartApp() - app.validateUser(knownLoginHostConfig, knownUserConfig, usesWelcomeDiscovery, expectAdvancedAuth = expectAdvancedAuth) + app.validateUser(knownLoginHostConfig, knownUserConfig, usesWelcomeDiscovery, expectAdvancedAuth = expectAdvancedAuth, isDpop = isDpop) } /** @@ -413,10 +414,12 @@ abstract class AuthFlowTest { fun switchToUserAndValidateUser( knownUserConfig: KnownUserConfig, knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + expectAdvancedAuth: Boolean = true, + isDpop: Boolean = false, ) { app.switchToUser(knownUserConfig) composeTestRule.waitForIdle() - app.validateUser(knownLoginHostConfig, knownUserConfig, isMultiUser = true) + app.validateUser(knownLoginHostConfig, knownUserConfig, isMultiUser = true, expectAdvancedAuth = expectAdvancedAuth, isDpop = isDpop) } companion object { @@ -487,7 +490,7 @@ abstract class AuthFlowTest { AuthorizationPageObject(composeTestRule).tapAllowAfterLogin(ADVANCED_AUTH) app.waitForAppLoad() - app.validateUser(REGULAR_AUTH, user, expectAdvancedAuth = true) + app.validateUser(REGULAR_AUTH, user, expectAdvancedAuth = true, isDpop = useDPoP) app.validateOAuthValues(knownAppConfig, scopeSelection = EMPTY) app.validateApiRequest() } @@ -572,6 +575,8 @@ abstract class AuthFlowTest { knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, scopeSelection: ScopeSelection = EMPTY, knownUserConfig: KnownUserConfig = user, + expectAdvancedAuth: Boolean = true, + isDpop: Boolean = false, ) { val (preAccessToken, preRefreshToken) = app.getTokens() app.migrateToNewApp(knownAppConfig, scopeSelection) @@ -581,7 +586,7 @@ abstract class AuthFlowTest { assert(preAccessToken != postAccessToken) assert(preRefreshToken != postRefreshToken) - app.validateUser(knownLoginHostConfig, knownUserConfig) + app.validateUser(knownLoginHostConfig, knownUserConfig, expectAdvancedAuth = expectAdvancedAuth, isDpop = isDpop) app.validateOAuthValues(knownAppConfig, scopeSelection) // Assert new tokens work @@ -593,9 +598,10 @@ abstract class AuthFlowTest { isRtr: Boolean, isDpop: Boolean = false, knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + expectAdvancedAuth: Boolean = true, + isMultiUser: Boolean = false, ) { val (preAccessToken, preRefreshToken) = app.getTokens() - val preNonce = if (isDpop) app.getDpopInfo().nonce else null app.revokeAccessToken() app.validateApiRequest() val (postAccessToken, postRefreshToken) = app.getTokens() @@ -611,9 +617,8 @@ abstract class AuthFlowTest { if (isDpop) { val postNonce = app.getDpopInfo().nonce assert(postNonce.isNotEmpty()) { "DPoP nonce should be non-empty after refresh" } - assert(preNonce != postNonce) { "DPoP nonce should have changed after token refresh (server issues new nonce with each /token response)" } } - app.validateUserAgent(knownLoginHostConfig = knownLoginHostConfig, isRtr = isRtr, isDpop = isDpop) + app.validateUserAgent(knownLoginHostConfig = knownLoginHostConfig, expectAdvancedAuth = expectAdvancedAuth, isMultiUser = isMultiUser, isRtr = isRtr, isDpop = isDpop) } } \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml b/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml index f2b7293877..6635ac5b12 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml +++ b/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml @@ -92,6 +92,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +