Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c52eefa
@W-22355537: [MSDK Android] App Attestation Public API (Production Lo…
JohnsonEricAtSalesforce May 7, 2026
b3489e5
@W-22355537: [MSDK Android] App Attestation Public API (Instrumented …
JohnsonEricAtSalesforce May 7, 2026
2842a8a
@W-22355537: [MSDK Android] App Attestation Public API (Convert AppAt…
JohnsonEricAtSalesforce May 7, 2026
a56c6ec
@W-22355537: [MSDK Android] App Attestation Public API (Enable AppAtt…
JohnsonEricAtSalesforce May 8, 2026
cdf837d
@W-22355537: [MSDK Android] App Attestation Public API (TEMPORARY: Up…
JohnsonEricAtSalesforce Apr 30, 2026
6d061f5
@W-22355537: [MSDK Android] App Attestation Public API (Update Mobile…
JohnsonEricAtSalesforce May 8, 2026
0daa91d
@W-22355537: [MSDK Android] App Attestation Public API (Add Thread Sa…
JohnsonEricAtSalesforce May 8, 2026
506196a
@W-22355537: [MSDK Android] App Attestation Public API (Fix Intermitt…
JohnsonEricAtSalesforce May 8, 2026
78b1ccb
@W-22355537: [MSDK Android] App Attestation Public API (Improve Test …
JohnsonEricAtSalesforce May 8, 2026
cfd9ac0
@W-22355537: [MSDK Android] App Attestation Public API (Fix Test Isol…
JohnsonEricAtSalesforce May 8, 2026
febface
@W-22355537: [MSDK Android] App Attestation Public API (Revert getDev…
JohnsonEricAtSalesforce May 9, 2026
d7fb7d5
@W-22355537: [MSDK Android] App Attestation Public API (Restore Tests)
JohnsonEricAtSalesforce May 9, 2026
4f9d02c
@W-22355537: [MSDK Android] App Attestation Public API (Update Remote…
JohnsonEricAtSalesforce May 11, 2026
ddb33e3
@W-22355537: [MSDK Android] App Attestation Public API (Refactor OAut…
JohnsonEricAtSalesforce May 12, 2026
1e7fbef
@W-22355537: [MSDK Android] App Attestation Public API (Restore @Igno…
JohnsonEricAtSalesforce May 12, 2026
8ade6e6
@W-22355537: [MSDK Android] App Attestation Public API (Add Test For …
JohnsonEricAtSalesforce May 12, 2026
76eede2
@W-22355537: [MSDK Android] App Attestation Public API (Remove Redund…
JohnsonEricAtSalesforce May 12, 2026
c9a2e8a
@W-22355537: [MSDK Android] App Attestation Public API (Add Test Cove…
JohnsonEricAtSalesforce May 12, 2026
8fbe46b
Revert "@W-22355537: [MSDK Android] App Attestation Public API (Resto…
JohnsonEricAtSalesforce May 12, 2026
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 @@ -52,6 +52,22 @@ public class MobileSyncSDKManager extends SmartStoreSDKManager {

private static final String TAG = "MobileSyncSDKManager";

/**
* Protected constructor.
*
* @param context Application context.
* @param mainActivity Activity that should be launched after the login flow.
* @param loginActivity Login activity.
* @param nativeLoginActivity Native login activity.
* @param googleCloudProjectId Google Cloud project ID for app attestation (nullable).
*/
protected MobileSyncSDKManager(Context context, Class<? extends Activity> mainActivity,
Class<? extends Activity> loginActivity,
Class<? extends Activity> nativeLoginActivity,
Long googleCloudProjectId) {
super(context, mainActivity, loginActivity, nativeLoginActivity, googleCloudProjectId);
}

/**
* Protected constructor.
*
Expand All @@ -63,7 +79,7 @@ public class MobileSyncSDKManager extends SmartStoreSDKManager {
protected MobileSyncSDKManager(Context context, Class<? extends Activity> mainActivity,
Class<? extends Activity> loginActivity,
Class<? extends Activity> nativeLoginActivity) {
super(context, mainActivity, loginActivity, nativeLoginActivity);
this(context, mainActivity, loginActivity, nativeLoginActivity, null);
}

private static void init(Context context, Class<? extends Activity> mainActivity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import androidx.compose.runtime.Composable
import androidx.core.content.ContextCompat.RECEIVER_EXPORTED
import androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED
import androidx.core.content.ContextCompat.registerReceiver
import androidx.core.net.toUri
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
Expand Down Expand Up @@ -97,6 +98,7 @@ import com.salesforce.androidsdk.auth.NativeLoginManager
import com.salesforce.androidsdk.auth.OAuth2.LogoutReason
import com.salesforce.androidsdk.auth.OAuth2.LogoutReason.UNKNOWN
import com.salesforce.androidsdk.auth.OAuth2.revokeRefreshToken
import com.salesforce.androidsdk.auth.RemoteAccessConsumerKeyProvider
import com.salesforce.androidsdk.auth.idp.SPConfig
import com.salesforce.androidsdk.auth.idp.interfaces.IDPManager
import com.salesforce.androidsdk.auth.idp.interfaces.SPManager
Expand Down Expand Up @@ -176,13 +178,17 @@ import com.salesforce.androidsdk.security.interfaces.ScreenLockManager as Screen
* @param context The Android context
* @param mainActivity Activity that should be launched after the login flow
* @param loginActivity Login activity
* @param googleCloudProjectId The Google Cloud Project ID to use with
* Google Play Integrity API and Salesforce App Attestation or null to
* disable both features
*/
open class SalesforceSDKManager protected constructor(
@JvmField
protected val context: Context,
mainActivity: Class<out Activity>,
private val loginActivity: Class<out Activity>? = null,
internal val nativeLoginActivity: Class<out Activity>? = null,
googleCloudProjectId: Long? = null,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@wmathurin, here's the relocated googleCloudProjectId in the SalesforceSDKManager constructor that subclasses use. I noticed that our RestExplorerApp sample subclasses, so this gives that app a convenient way to pass this value.

Are there other constructor use cases we'd need to expose? I've yet to do a complete inventory of all our samples and templates to see what they are up to and wanted to get your feedback early.

) : DefaultLifecycleObserver {

constructor(
Expand Down Expand Up @@ -229,50 +235,40 @@ open class SalesforceSDKManager protected constructor(

/**
* The client side implementation of the Salesforce App Attestation External
* Client App (ECA) Plugin or null when app attestation is disabled.
* Client App (ECA) Plugin or null when Salesforce App Attestation is
* disabled.
*
* This property is not intended for public use outside of Salesforce Mobile
* SDK
*
* TODO: Make this Kotlin-internal once it is no longer referenced by Java. ECJ20260420
*/
@Volatile
var appAttestationClient: AppAttestationClient? = null
@VisibleForTesting
internal set

/** Lock object for synchronized access to the app Attestation Client */
private val appAttestationClientLock = Any()
val appAttestationClient: AppAttestationClient? by lazy {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@wmathurin - The AppAttestationClient initialization is once at construction now.

googleCloudProjectId?.let { createAppAttestationClient(it) }
}

/**
* Updates the Salesforce App Attestation ECA Plugin Client for the selected
* login server and matching Google Cloud Project ID. When using App
* Attestation, this value must match the linked Google Cloud Project ID
* for the app in Google Play Console's Play Integrity API and provided to
* the Salesforce App Attestation External Client App Plugin.
* Creates the Salesforce App Attestation ECA Plugin Client for the selected
* Google Cloud Project ID. When using Salesforce App Attestation, this
* value must match the linked Google Cloud Project ID for the app in Google
* Play Console's Play Integrity API and provided to the Salesforce App
* Attestation External Client App Plugin.
*
* @param apiHostName The Salesforce App Attestation External Client App
* (ECA) Plugin Challenge API Host Name. This usually matches the selected
* login server
* @param googleCloudProjectId The Google Cloud Project ID or null to
* disable Salesforce App Attestation
*/
fun updateAppAttestationClient(
apiHostName: String,
fun createAppAttestationClient(
googleCloudProjectId: Long? = null
) {
synchronized(appAttestationClientLock) {
appAttestationClient = googleCloudProjectId?.let { appAttestationGoogleCloudProjectId ->
AppAttestationClient(
context = appContext,
apiHostName = apiHostName,
deviceId = deviceId,
googleCloudProjectId = appAttestationGoogleCloudProjectId,
remoteAccessConsumerKey = getBootConfig(appContext).remoteAccessConsumerKey,
restClient = clientManager.peekUnauthenticatedRestClient()
)
}
}
) = googleCloudProjectId?.let { appAttestationGoogleCloudProjectId ->
AppAttestationClient(
context = appContext,
deviceId = deviceId,
googleCloudProjectId = appAttestationGoogleCloudProjectId,
remoteAccessConsumerKeyProvider = RemoteAccessConsumerKeyProvider { loginServer ->
resolveOAuthConfigForLoginServer(loginServer).consumerKey
},
restClient = clientManager.peekUnauthenticatedRestClient()
)
}

/**
Expand All @@ -294,6 +290,32 @@ open class SalesforceSDKManager protected constructor(

internal var debugOverrideAppConfig: OAuthConfig? = null

/**
* Resolves the OAuth configuration for the specified login server.
*
* Resolution order:
* 1. Debug override configuration (when [isDebugBuild] is true and override
* is set)
* 2. Dynamic app configuration for the login host via
* [appConfigForLoginHost]
* 3. Static boot configuration from bootconfig.xml
*
* This allows apps to use different OAuth configs per server while
* supporting debug overrides for development/testing.
*
* @param loginServer The login server URL
* @return The OAuth configuration for the specified server
*/
internal suspend fun resolveOAuthConfigForLoginServer(
loginServer: String
): OAuthConfig {
val debugOverride = debugOverrideAppConfig
return when {
isDebugBuild && debugOverride != null -> debugOverride
else -> appConfigForLoginHost(loginServer) ?: OAuthConfig(getBootConfig(appContext))
}
}

/** The class for the account switcher activity */
var accountSwitcherActivityClass = AccountSwitcherActivity::class.java

Expand Down Expand Up @@ -1528,7 +1550,7 @@ open class SalesforceSDKManager protected constructor(
}

/** Indicates if this is a debug build */
internal val isDebugBuild
internal open val isDebugBuild
get() = DEBUG


Expand Down Expand Up @@ -1717,19 +1739,24 @@ open class SalesforceSDKManager protected constructor(
* @param context The Android context
* @param mainActivity The app's main activity class
* @param loginActivity The app login activity class
* @param googleCloudProjectId The Google Cloud Project ID to use with
* Google Play Integrity API and Salesforce App Attestation or null to
* disable both features
*/
private fun init(
context: Context,
mainActivity: Class<out Activity>,
loginActivity: Class<out Activity>? = null,
nativeLoginActivity: Class<out Activity>? = null,
googleCloudProjectId: Long? = null,
) {
if (INSTANCE == null) {
INSTANCE = SalesforceSDKManager(
context,
mainActivity,
loginActivity,
nativeLoginActivity,
googleCloudProjectId,
)
}
initInternal(context)
Expand Down Expand Up @@ -1958,6 +1985,9 @@ open class SalesforceSDKManager protected constructor(
shareBrowserSessionEnabled = false
)

// Disable Salesforce App Attestation for login servers that are not My Domain servers.
appAttestationClient?.apiHostName = null
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@wmathurin - I went without the app-provided callback to determine the Challenge API Host. Here, we seem to know that we're not using a My Domain and that means App Attestation will be disabled. Is that correct?

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.

(I'm still hoping we can move to attesting on code exchange only - but that will only fly if user agent flow can be blocked for a given ECA which is not possible today --- stay tuned).


return@withTimeoutOrNull
}

Expand All @@ -1966,6 +1996,9 @@ open class SalesforceSDKManager protected constructor(
browserLoginEnabled = authConfig?.isBrowserLoginEnabled ?: false,
shareBrowserSessionEnabled = authConfig?.isShareBrowserSessionEnabled ?: false
)

// Consider enabling Salesforce App Attestation for login servers that are My Domain servers.
appAttestationClient?.apiHostName = loginServer.toUri().host
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Similar to the comment a few lines before, here we seem to know we are using a My Domain host and enable App Attestation. How's that look?

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ import java.nio.charset.StandardCharsets.UTF_8
import java.security.MessageDigest
import java.util.Base64

/**
* Provides the Salesforce External Client App (ECA) remote access consumer key.
* This is typically sourced from the boot configuration.
*
* This interface is not intended for public use outside of Salesforce Mobile
* SDK.
*/
fun interface RemoteAccessConsumerKeyProvider {
/**
* Returns the current remote access consumer key or null if not available.
* @param loginServer The login server
* @return The remote access consumer key or null if not available
*/
suspend fun getRemoteConsumerKey(loginServer: String): String?
}

/**
* App attestation features supporting the Salesforce App Attestation External
* Client App (ECA) Plugin, the Salesforce Challenge API, Google Play Integrity
Expand All @@ -55,35 +71,35 @@ import java.util.Base64
*
* TODO: Make this class internal once Java support is removed. ECJ20260421
*
* @param apiHostName The Salesforce App Attestation Challenge API host
* @param deviceId The device id, usually provided by the Salesforce SDK Manager
* @param googleCloudProjectId The Google Cloud Project ID used with Google Play
* Integrity API
* @param integrityManager The Google Play App Integrity API Integrity Manager.
* This parameter is intended for testing purposes only. Defaults to a new
* instance
* @param remoteAccessConsumerKey The Salesforce Connected App (CA) or External
* Client App (ECA)remote access consumer key, usually provided by the boot
* @param remoteAccessConsumerKeyProvider Provides the Salesforce External
* Client App (ECA) remote access consumer key, usually sourced from the boot
* config
* @param restClient The REST client, usually provided by the Salesforce SDK
* Manager's unauthenticated REST client
*/
class AppAttestationClient(
context: Context,
@property:VisibleForTesting
internal val apiHostName: String,
@property:VisibleForTesting
internal val deviceId: String,
@property:VisibleForTesting
internal val googleCloudProjectId: Long,
@property:VisibleForTesting
internal val integrityManager: StandardIntegrityManager = createStandard(context),
@property:VisibleForTesting
internal val remoteAccessConsumerKey: String,
internal val remoteAccessConsumerKeyProvider: RemoteAccessConsumerKeyProvider,
@property:VisibleForTesting
internal val restClient: RestClient,
) {

/** The Salesforce App Attestation Challenge API host or null to disable Salesforce App Attestation */
@Volatile
internal var apiHostName: String? = null

/** The Google Play Integrity API Token Provider */
@VisibleForTesting
Expand Down Expand Up @@ -222,24 +238,41 @@ class AppAttestationClient(
* Fetches a new "Challenge" from the Salesforce App Attestation External
* Client App (ECA) Plug-In.
*
* This method is not intended for public use outside of Salesforce Mobile
* SDK.
*
* TODO: Make this Kotlin-internal once it is no longer referenced by Java. ECJ20260420
*
* @return The Salesforce App Attestation ECA Plug-In's "Challenge"
* @return The Salesforce App Attestation ECA Plug-In challenge, or null if
* App Attestation is disabled (apiHostName is null) or the remote access
* consumer key is unavailable
* @throws java.io.IOException if the network request fails
* @throws org.json.JSONException if the response cannot be parsed
*/
fun fetchMobileAppAttestationChallenge(): String {
internal suspend fun fetchMobileAppAttestationChallenge(): String? {
// Create the Salesforce App Attestation Challenge API client and fetch a new challenge.
val apiHost = apiHostName ?: return null
val appAttestationChallengeApiClient = AppAttestationChallengeApiClient(
apiHostName = apiHostName,
apiHostName = apiHost,
restClient = restClient
)
return appAttestationChallengeApiClient.fetchChallenge(
attestationId = deviceId,
remoteConsumerKey = remoteAccessConsumerKey
remoteConsumerKey = remoteAccessConsumerKeyProvider.getRemoteConsumerKey(apiHost) ?: return null
)
}

/**
* Fetches a new "Challenge" from the Salesforce App Attestation External
* Client App (ECA) Plug-In.
*
* This method is not intended for public use outside of Salesforce Mobile
* SDK.
*
* @return The Salesforce App Attestation ECA Plug-In challenge, or null if
* App Attestation is disabled (apiHostName is null) or the remote access
* consumer key is unavailable
* @throws java.io.IOException if the network request fails
* @throws org.json.JSONException if the response cannot be parsed
*/
fun fetchMobileAppAttestationChallengeBlocking(): String? = runBlocking {
fetchMobileAppAttestationChallenge()
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ internal class NativeLoginManager(
AUTHORIZATION to "$AUTH_AUTHORIZATION_VALUE_BASIC $encodedCreds",
)
val attestationValue = SalesforceSDKManager.getInstance().appAttestationClient?.run {
val challenge = fetchMobileAppAttestationChallenge()
val challenge = fetchMobileAppAttestationChallenge() ?: return@run null
createAppAttestation(challenge) ?: return@run null
}
val authRequestBody = createRequestBody(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ public static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAcce
sb.append(QUESTION).append(DEVICE_ID).append(EQUAL).append(salesforceSdkManager.getDeviceId());

final AppAttestationClient appAttestationClient = salesforceSdkManager.getAppAttestationClient();
final String challenge = appAttestationClient != null ? appAttestationClient.fetchMobileAppAttestationChallenge() : null;
final String challenge = appAttestationClient != null ? appAttestationClient.fetchMobileAppAttestationChallengeBlocking() : null;
final String attestationValue = challenge != null ? appAttestationClient.createAppAttestationBlocking(challenge) : null;
if (attestationValue != null) {
// Note: The attestation value is appended to the token endpoint
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ internal class IDPAuthCodeHelper @VisibleForTesting internal constructor(

// Add Salesforce Mobile App Attestation parameter to authorization URL if applicable.
val additionalParams = appAttestationClient?.run {
val challenge = fetchMobileAppAttestationChallenge()
val challenge = fetchMobileAppAttestationChallenge() ?: return@run null
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's convenient I'd previously separated the challenge and attestation fetch at the call site, so here it's just a second guard when the Challenge Host is not set.

val attestation = createAppAttestation(challenge) ?: return@run null
mapOf(ATTESTATION to attestation)
}
Expand Down
Loading
Loading