Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,11 @@ 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
import com.salesforce.androidsdk.config.LoginServerManager.LoginServer
import com.salesforce.androidsdk.config.OAuthConfig
import com.salesforce.androidsdk.config.RuntimeConfig.ConfigKey.OnlyShowAuthorizedHosts
Expand All @@ -80,6 +83,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

/**
Expand Down Expand Up @@ -202,6 +206,10 @@ open class LoginViewModel(
/** Additional Auth Values used for login. */
open var additionalParameters = hashMapOf<String, String>()

/** 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

Expand Down Expand Up @@ -539,6 +547,8 @@ open class LoginViewModel(

val additionalParameters = mutableMapOf<String, String>()

addDpopJktIfNeeded(server, sdkManager, additionalParameters)

val authorizationUrl = OAuth2.getAuthorizationUrl(
/* useWebServerAuthentication = */ true,
sdkManager.useHybridAuthentication,
Expand Down Expand Up @@ -584,6 +594,8 @@ open class LoginViewModel(
val codeVerifier = getRandom128ByteKey().also { codeVerifier = it }
val codeChallenge = getSHA256Hash(codeVerifier)

addDpopJktIfNeeded(server, sdkManager, additionalParams)

val webServerAuthorizationUrl = OAuth2.getAuthorizationUrl(
/* useWebServerAuthentication = */ true,
sdkManager.useHybridAuthentication,
Expand Down Expand Up @@ -669,7 +681,8 @@ open class LoginViewModel(
}
val verifier = if (isUsingFrontDoorBridge) frontdoorBridgeCodeVerifier else codeVerifier

val credentialsIdentifier = java.util.UUID.randomUUID().toString()
val credentialsIdentifier = pendingCredentialsIdentifier?.also { pendingCredentialsIdentifier = null }
Comment thread
sfdctaka marked this conversation as resolved.
?: java.util.UUID.randomUUID().toString()

val tokenResponse = exchangeCode(
HttpAccess.DEFAULT,
Expand Down Expand Up @@ -708,6 +721,42 @@ 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 addDpopJktIfNeeded(
server: String,
sdkManager: SalesforceSDKManager,
params: MutableMap<String, String>,
) {
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")
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)
Comment thread
sfdctaka marked this conversation as resolved.
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,81 @@ class LoginViewModelTest {

// endregion

// region DPoP dpop_jkt Tests

@Test
fun generateAuthorizationUrl_WhenUseDPoP_AddsDpopJktToUrl() = runBlocking {
val sdkManagerMock = mockk<SalesforceSDKManager>(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://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"
}
// 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<SalesforceSDKManager>(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<SalesforceSDKManager>(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://myorg.my.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<SalesforceSDKManager>(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
Expand Down Expand Up @@ -643,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,
Expand Down Expand Up @@ -705,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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
}

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ class RefreshTokenMigrationTests: AuthFlowTest() {
useDPoP: Boolean,
knownLoginHostConfig: KnownLoginHostConfig,
knownUserConfig: KnownUserConfig,
forceAdvancedAuthentication: Boolean,
useWelcomeDiscovery: Boolean,
isMultiUser: Boolean,
) {
Expand All @@ -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,
Expand Down
Loading
Loading