Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ object DPoPKeyManager {
fun isDPoPTokenType(tokenType: String?): Boolean =
tokenType?.equals(DPOP_TOKEN_TYPE, ignoreCase = true) == true

/**
* Returns whether the persisted fields required to use [tokenType] are internally
* consistent. A DPoP-bound credential must identify the key pair used for its proof;
* Bearer and transitional credentials do not require that identifier.
*/
@JvmStatic
fun hasCompleteDPoPCredentials(
credentialsIdentifier: String?,
tokenType: String?
): Boolean = !isDPoPTokenType(tokenType) || !credentialsIdentifier.isNullOrBlank()

private const val TAG = "DPoPKeyManager"
private val keyPairCache = ConcurrentHashMap<String, KeyPair>()
private val keyStoreLock = Any()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import com.salesforce.androidsdk.auth.OAuth2
import com.salesforce.androidsdk.util.SalesforceSDKLogger
import okhttp3.Request
import okhttp3.Response
import java.io.IOException

/**
* Public convenience API for app developers stamping DPoP/Bearer authorization
Expand All @@ -51,17 +52,22 @@ object DPoPRequestDecorator {
private const val NONCE_ERROR_VALUE = "use_dpop_nonce"

/**
* Stamps the complete [Authorization] and optional [DPoP] proof header set on [builder].
* No-op when [UserAccount.getAuthToken] is null or empty.
* Stamps [Authorization] and, if the account is DPoP-bound, a [DPoP] proof header
* on [builder]. No-op when [UserAccount.getAuthToken] is null or empty. Rejects an
* incomplete DPoP credential with [IOException] before mutating [builder].
*/
fun applyAuthHeaders(builder: Request.Builder, userAccount: UserAccount) {
val tokenType = userAccount.tokenType
val credentialsIdentifier = userAccount.credentialsIdentifier
requireCompleteDPoPCredentials(credentialsIdentifier, tokenType)
Comment thread
wmathurin marked this conversation as resolved.

val authToken = userAccount.authToken ?: return
if (authToken.isEmpty()) return

applyAuthHeaders(
builder,
userAccount.credentialsIdentifier,
userAccount.tokenType,
credentialsIdentifier,
tokenType,
authToken,
userAccount.uiSid,
)
Expand All @@ -80,6 +86,7 @@ object DPoPRequestDecorator {
authToken: String?,
uiSid: String?,
) {
requireCompleteDPoPCredentials(credentialsIdentifier, tokenType)
if (authToken.isNullOrEmpty()) return

val requestPath = builder.build().url.encodedPath
Expand Down Expand Up @@ -139,7 +146,8 @@ object DPoPRequestDecorator {

/**
* Package-private helper used by RestClient.OAuthRefreshInterceptor to delegate
* the proof-building step without constructing a UserAccount.
* the proof-building step without constructing a UserAccount. An incomplete DPoP
* credential throws [IOException] so OkHttp reports it through normal request failure.
*/
@JvmName("attachProof")
internal fun attachProof(
Expand All @@ -148,6 +156,7 @@ object DPoPRequestDecorator {
tokenType: String?,
authToken: String?
) {
requireCompleteDPoPCredentials(credentialsIdentifier, tokenType)
Comment thread
wmathurin marked this conversation as resolved.
if (!DPoPKeyManager.shouldAttachDPoP(credentialsIdentifier, tokenType)) return
try {
val request = builder.build()
Expand All @@ -164,4 +173,17 @@ object DPoPRequestDecorator {
SalesforceSDKLogger.e(TAG, "Failed to attach DPoP proof", e)
}
}

private fun requireCompleteDPoPCredentials(
credentialsIdentifier: String?,
tokenType: String?
) {
if (!DPoPKeyManager.hasCompleteDPoPCredentials(credentialsIdentifier, tokenType)) {
throw IncompleteDPoPCredentialsException()
}
}

private class IncompleteDPoPCredentialsException : IOException(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One small observation: IncompleteDPoPCredentialsException is currently defined as private inside DPoPRequestDecorator.kt.

Because it's private, external consumer applications or other internal SDK orchestrators/synchronization engines (such as SyncTask) won't be able to catch this specific exception class. They will only see a generic IOException, meaning they cannot easily differentiate between a local corrupt/incomplete credential state and a standard network issue (like a transient socket timeout) to trigger proactive recovery/telemetry.

If there is any plan or possibility that callers would want to programmatically detect and recover from this state, it might be worth exposing it as internal or package-private (similar to RefreshTokenRevokedException or MalformedTokenException).

"DPoP credentials require a non-blank credentials identifier"
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException;
import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse;
import com.salesforce.androidsdk.auth.OAuth2.TokenErrorResponse;
import com.salesforce.androidsdk.auth.dpop.DPoPKeyManager;
import com.salesforce.androidsdk.rest.RestClient.ClientInfo;
import com.salesforce.androidsdk.util.SalesforceSDKLogger;

Expand Down Expand Up @@ -242,7 +243,9 @@ private UserAccount validateUser(boolean requireRefreshFields, @Nullable UserAcc
}
if (user == null
|| isMissing(user.getUserId())
|| isMissing(user.getOrgId())) {
|| isMissing(user.getOrgId())
|| !DPoPKeyManager.hasCompleteDPoPCredentials(
user.getCredentialsIdentifier(), user.getTokenType())) {
return null;
}
if (requireRefreshFields && (isMissing(user.getRefreshTokenForPersistence())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,28 @@ class SalesforceSDKManagerClientManagerTest {
}
}

@Test
fun getRestClient_withDPoPAccountMissingCredentialsIdentifier_removesItWithoutCallback() {
val corruptUser = UserAccountBuilder.getInstance()
.populateFromUserAccount(buildUser("corrupt-dpop"))
.tokenType("dPoP")
.allowUnset(true)
.credentialsIdentifier(null)
.build()
userAccountManager.createAccount(corruptUser)
val account = requireNotNull(userAccountManager.buildAccount(corruptUser))
val activity = mockk<Activity>(relaxed = true)
var callbackCount = 0

sdkManager.getRestClient(activity) { callbackCount++ }

assertEquals(0, callbackCount)
assertFalse(accountManager.getAccountsByType(account.type).contains(account))
verify(exactly = 0) {
activity.startActivityForResult(any<Intent>(), any())
}
}

@Test
fun getRestClient_resolvesCurrentAccountExactlyOnce() {
/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,18 @@ class DPoPKeyManagerTest {
assertFalse(DPoPKeyManager.isDPoPTokenType("bearer"))
}

@Test
fun test_hasCompleteDPoPCredentials_requiresNonBlankIdentifierOnlyForDPoP() {
assertTrue(DPoPKeyManager.hasCompleteDPoPCredentials("credential-id", "DPoP"))
assertTrue(DPoPKeyManager.hasCompleteDPoPCredentials("credential-id", "dPoP"))
assertFalse(DPoPKeyManager.hasCompleteDPoPCredentials(null, "DPoP"))
assertFalse(DPoPKeyManager.hasCompleteDPoPCredentials("", "dpop"))
assertFalse(DPoPKeyManager.hasCompleteDPoPCredentials(" ", "DPOP"))

assertTrue(DPoPKeyManager.hasCompleteDPoPCredentials(null, "Bearer"))
assertTrue(DPoPKeyManager.hasCompleteDPoPCredentials("", null))
}

@Test
fun test_shouldAttachDPoP_lowercaseDPoPTokenType_returnsTrue() {
// Regression for W-24027018: server returns lowercase "dpop" in refresh responses.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException

/**
* Unit tests for [DPoPRequestDecorator], the public convenience API for stamping
Expand Down Expand Up @@ -139,6 +141,44 @@ class DPoPRequestDecoratorTest {
assertNull(request.header(DPoPRequestDecorator.DPOP_HEADER))
}

@Test
fun applyAuthHeaders_dpopAccountWithMissingIdentifier_failsBeforeAddingHeaders() {
listOf<String?>(null, "", " ").forEach { identifier ->
val builder = requestBuilder()

assertThrows(IOException::class.java) {
DPoPRequestDecorator.applyAuthHeaders(
builder,
userAccount(
tokenType = "dPoP",
credentialsIdentifier = identifier,
),
)
}

assertNull(builder.build().header("Authorization"))
assertNull(builder.build().header(DPoPRequestDecorator.DPOP_HEADER))
}
}

@Test
fun applyAuthHeaders_dpopAccountWithMissingToken_failsClosed() {
val builder = requestBuilder()

assertThrows(IOException::class.java) {
DPoPRequestDecorator.applyAuthHeaders(
builder,
userAccount(
authToken = null,
tokenType = "DPoP",
credentialsIdentifier = null,
),
)
}

assertNull(builder.build().header("Authorization"))
}

@Test
fun applyAuthHeaders_dpopAccount_isUseDPoPFalse_proofStillAttached() {
val sdkManager = com.salesforce.androidsdk.app.SalesforceSDKManager.getInstance()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,50 @@ public void testPeekRestClientWithUserFailsClosedWhenSuppliedUserMissingUserId()
Assert.assertNull(clientManager.peekRestClient(incompleteUser));
}

/** Persisted DPoP credentials without a key identifier are rejected as corrupt. */
@Test
public void testPeekRestClientWithDPoPAndNullCredentialsIdentifierReturnsNull() {
final UserAccount corruptUser = UserAccountBuilder.getInstance()
.populateFromUserAccount(UserAccountTest.createTestAccount())
.tokenType("DPoP")
.allowUnset(true)
.credentialsIdentifier(null)
.build();
userAccountManager.createAccount(corruptUser);
clientManager = new ClientManager(targetContext, corruptUser);

Assert.assertNull(clientManager.peekRestClient());
}

/** DPoP matching is case-insensitive and whitespace is not a usable key identifier. */
@Test
public void testPeekRestClientWithMixedCaseDPoPAndBlankCredentialsIdentifierReturnsNull() {
final UserAccount corruptUser = UserAccountBuilder.getInstance()
.populateFromUserAccount(UserAccountTest.createTestAccount())
.tokenType("dPoP")
.credentialsIdentifier(" ")
.build();
userAccountManager.createAccount(corruptUser);
clientManager = new ClientManager(targetContext, corruptUser);

Assert.assertNull(clientManager.peekRestClient());
}

/** Bearer accounts do not require a DPoP credentials identifier. */
@Test
public void testPeekRestClientWithBearerAndNullCredentialsIdentifierReturnsClient() {
final UserAccount bearerUser = UserAccountBuilder.getInstance()
.populateFromUserAccount(UserAccountTest.createTestAccount())
.tokenType("Bearer")
.allowUnset(true)
.credentialsIdentifier(null)
.build();
userAccountManager.createAccount(bearerUser);
clientManager = new ClientManager(targetContext, bearerUser);

Assert.assertNotNull(clientManager.peekRestClient());
}

/**
* Checks there are no test accounts
*/
Expand Down
Loading
Loading