From 082b01890c09aad6de4a4b736fdf01e32a63f711 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 12 Mar 2026 12:27:13 -0600 Subject: [PATCH] @W-21146662: [Android] App attestation integration testing (Initial Proof-Of-Concept) --- libs/SalesforceSDK/build.gradle.kts | 1 + .../androidsdk/app/SalesforceSDKManager.kt | 129 ++++++++++++++++++ .../androidsdk/auth/NativeLoginManager.kt | 18 ++- .../salesforce/androidsdk/auth/OAuth2.java | 17 +++ .../rest/AppAttestationChallengeApiClient.kt | 88 ++++++++++++ .../AppAttestationChallengeApiException.kt | 41 ++++++ 6 files changed, 289 insertions(+), 5 deletions(-) create mode 100644 libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/AppAttestationChallengeApiClient.kt create mode 100644 libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/AppAttestationChallengeApiException.kt diff --git a/libs/SalesforceSDK/build.gradle.kts b/libs/SalesforceSDK/build.gradle.kts index e5bafd9348..738b581981 100644 --- a/libs/SalesforceSDK/build.gradle.kts +++ b/libs/SalesforceSDK/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { api("androidx.browser:browser:1.8.0") // Update requires API 36 compileSdk api("androidx.work:work-runtime-ktx:2.10.3") + implementation("com.google.android.play:integrity:1.6.0") implementation("com.google.accompanist:accompanist-drawablepainter:0.37.3") implementation("com.google.android.material:material:1.13.0") // remove this when all xml is gone implementation("androidx.appcompat:appcompat:1.7.1") diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index 8f3eb0ce9f..e20a8ac414 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -52,6 +52,7 @@ import android.provider.Settings.Secure.ANDROID_ID import android.provider.Settings.Secure.getString import android.text.TextUtils.isEmpty import android.text.TextUtils.join +import android.util.Log import android.view.View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR import android.view.View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR import android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS @@ -71,6 +72,10 @@ import androidx.window.core.layout.WindowHeightSizeClass import androidx.window.core.layout.WindowSizeClass import androidx.window.core.layout.WindowWidthSizeClass import androidx.window.layout.WindowMetricsCalculator +import com.google.android.play.core.integrity.IntegrityManagerFactory.createStandard +import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest import com.salesforce.androidsdk.BuildConfig.DEBUG import com.salesforce.androidsdk.R.string.account_type import com.salesforce.androidsdk.R.string.sf__dev_support_title @@ -123,6 +128,7 @@ import com.salesforce.androidsdk.push.PushNotificationInterface import com.salesforce.androidsdk.push.PushService import com.salesforce.androidsdk.push.PushService.Companion.pushNotificationsRegistrationType import com.salesforce.androidsdk.push.PushService.PushNotificationReRegistrationType.ReRegistrationOnAppForeground +import com.salesforce.androidsdk.rest.AppAttestationChallengeApiClient import com.salesforce.androidsdk.rest.ClientManager import com.salesforce.androidsdk.rest.NotificationsActionsResponseBody import com.salesforce.androidsdk.rest.NotificationsApiClient @@ -148,10 +154,17 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Default import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.lang.String.CASE_INSENSITIVE_ORDER import java.net.URI +import java.nio.charset.StandardCharsets.UTF_8 +import java.security.MessageDigest +import java.util.Base64 import java.util.Locale.US import java.util.SortedSet import java.util.UUID.randomUUID @@ -683,6 +696,122 @@ open class SalesforceSDKManager protected constructor( ) } + /** The Google Play Integrity API Token Provider */ + private var integrityTokenProvider: StandardIntegrityTokenProvider? = null + + /** + * A simple proof-of-concept to prepare for authorization and authorization + * token refresh with the Salesforce Mobile App Attestation Challenge API + * and Google Play Integrity API enabled. + * + * TODO: This will need to be made production-ready in the future. ECJ20260312 + // TODO: Discuss a suitable scope for this as attaching it to this singleton may further legacy patterns. ECJ20260312 + */ + fun testGooglePlayIntegrityApiPreparation() { + CoroutineScope(Default).launch { + // The app's corresponding Cloud Project Number. + // TODO: Determine where this value would be provided to or by Salesforce Mobile SDK. ECJ20260311 + val cloudProjectNumber = -1L // TODO: Google Cloud Project Number. ECJ20260311 + + // TODO: For production, determine where Salesforce Mobile SDK should encapsulate the logic of preparing the Google Play Integrity Manager and Token Provider. That can likely be a single method which encapsulates storing the token for later use in authorization plus refreshing the Token Provider when needed. ECJ20260311 + + // Create the Google Play Integrity Manager and Token Provider. + val integrityManager = createStandard(this@SalesforceSDKManager.appContext) + + // Prepare the Google Play Integrity token. Calling this prior to requesting the Integrity Token reduces the latency of the request. + integrityManager.prepareIntegrityToken( + PrepareIntegrityTokenRequest.builder() + .setCloudProjectNumber(cloudProjectNumber) + .build() + ).addOnSuccessListener { tokenProvider -> + integrityTokenProvider = tokenProvider + Log.i("AppAttestation", "Prepared Google Play Integrity Token Provider: '${tokenProvider}'.") + }.addOnFailureListener { exception -> + Log.e("AppAttestation", "Failed to prepare Google Play Integrity Token Provider: '${exception.message}'.") + } + } + } + + /** + * A simple proof-of-concept for fetching the Salesforce App Attestation + * ECA Plug-In's "Challenge". + * @return The Salesforce App Attestation ECA Plug-In's "Challenge" + */ + internal fun testSalesforceMobileAppAttestationChallengeRequest(): String { + // Create the Salesforce App Attestation Challenge API client and fetch a new challenge. + val appAttestationChallengeApiClient = AppAttestationChallengeApiClient( + apiHostName = "msdkappattestationtestorg.test1.my.pc-rnd.salesforce.com", // TODO: Replace with template placeholder. ECJ20260311 + restClient = clientManager.peekUnauthenticatedRestClient() + ) + val salesforceAppAttestationChallenge = appAttestationChallengeApiClient.fetchChallenge( + attestationId = deviceId, + remoteConsumerKey = getBootConfig(this@SalesforceSDKManager.appContext).remoteAccessConsumerKey + ) + + return salesforceAppAttestationChallenge + } + + /** + * A simple proof-of-concept for fetching a Google Play Integrity API Token + * using the Salesforce App Attestation ECA Plug-In's "Challenge" as the + * Request Hash. + * @return The Google Play Integrity API Token + */ + fun testOAuthAuthorizationAttestationRequest(): String? { + // Guards. + val integrityTokenProvider = integrityTokenProvider ?: return null + + // Fetch the Salesforce Mobile App Attestation Challenge. + val salesforceAppAttestationChallenge = testSalesforceMobileAppAttestationChallengeRequest() + val salesforceAppAttestationChallengeHashByteArray = MessageDigest.getInstance("SHA-256") + .digest(salesforceAppAttestationChallenge.toByteArray(UTF_8)) + val salesforceAppAttestationChallengeHashHexString = salesforceAppAttestationChallengeHashByteArray.joinToString("") { "%02x".format(it) } + + // Request the Google Play Integrity Token. + val integrityTokenResponse = integrityTokenProvider.request( + StandardIntegrityTokenRequest.builder() + .setRequestHash(salesforceAppAttestationChallengeHashHexString) + .build() + ) + val googlePlayIntegrityTask = integrityTokenResponse.addOnSuccessListener { response -> + Log.i("AppAttestation", "Received Google Play Integrity Token: '${response.token()}'.") + + }.addOnFailureListener { exception -> + // If the app uses the same token provider for too long, the token provider can expire which results in the INTEGRITY_TOKEN_PROVIDER_INVALID error on the next token request. You should handle this error by requesting a new provider. + Log.e("AppAttestation", "Failed To Receive Google Play Integrity Token: Message: '${exception.message}'.") + + // TODO: Handle the error by requesting a new Google Play Integrity Token Provider. ECJ20260311 + } + + // Wait for the Google Play Integrity API response and return the Base64-encoded Salesforce OAuth authorization attestation parameter JSON. + runBlocking { + googlePlayIntegrityTask.await() + } + return OAuthAuthorizationAttestation( + attestationId = deviceId, + integrityToken = googlePlayIntegrityTask.getResult().token() + ).toBase64String() + } + + /** + * A Salesforce OAuth 2.0 authorization "attestation" parameter. + * @param attestationId The attestation id used when creating the Salesforce + * Mobile App Attestation API Challenge. This is intended to be the + * Salesforce Mobile SDK device id + * @param integrityToken The token provided by the Google Play Integrity API + */ + @Serializable + internal data class OAuthAuthorizationAttestation( + val attestationId: String, + val integrityToken: String, + ) { + + /** + * Returns a Base64-encoded JSON representation of this object + */ + fun toBase64String(): String? = Base64.getEncoder().encodeToString(Json.encodeToString(serializer(), this).encodeToByteArray()) + } + /** * Optionally enables browser based login instead of web view login. * diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt index 64f33b9d84..2676da1beb 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt @@ -37,7 +37,9 @@ import android.util.Base64.encodeToString import android.util.Patterns.EMAIL_ADDRESS import androidx.core.os.bundleOf import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.app.SalesforceSDKManager.Companion.getInstance import com.salesforce.androidsdk.auth.NativeLoginManager.StartRegistrationRequestBody.UserData +import com.salesforce.androidsdk.auth.OAuth2.ATTESTATION import com.salesforce.androidsdk.auth.OAuth2.AUTHORIZATION import com.salesforce.androidsdk.auth.OAuth2.AUTHORIZATION_CODE import com.salesforce.androidsdk.auth.OAuth2.CLIENT_ID @@ -143,7 +145,9 @@ internal class NativeLoginManager( CONTENT_TYPE_HEADER_NAME to CONTENT_TYPE_VALUE_HTTP_POST, AUTHORIZATION to "$AUTH_AUTHORIZATION_VALUE_BASIC $encodedCreds", ) + val authorizationAttestationValue = getInstance().testOAuthAuthorizationAttestationRequest() val authRequestBody = createRequestBody( + ATTESTATION to authorizationAttestationValue, RESPONSE_TYPE to CODE_CREDENTIALS, CLIENT_ID to clientId, REDIRECT_URI to redirectUri, @@ -186,11 +190,13 @@ internal class NativeLoginManager( @VisibleForTesting internal fun isValidPassword(password: String): Boolean { - val containsNumber = password.contains("[0-9]".toRegex()) - val containsLetter = password.contains("[A-Za-z]".toRegex()) + // TODO: Revert this change after testing with administrator-created accounts that have non-compliant passwords. ECJ20260312 +// val containsNumber = password.contains("[0-9]".toRegex()) +// val containsLetter = password.contains("[A-Za-z]".toRegex()) - return containsNumber && containsLetter && password.length >= MIN_PASSWORD_LENGTH - && password.toByteArray().size <= MAX_PASSWORD_LENGTH_BYTES +// return containsNumber && containsLetter && password.length >= MIN_PASSWORD_LENGTH +// && password.toByteArray().size <= MAX_PASSWORD_LENGTH_BYTES + return true } private suspend fun suspendFinishAuthFlow(tokenResponse: RestResponse): NativeLoginResult { @@ -238,7 +244,9 @@ internal class NativeLoginManager( } } - private fun createRequestBody(vararg kvPairs: Pair): RequestBody { + private fun createRequestBody(vararg kvPairs: Pair): RequestBody { + // TODO: Review this. If the request body is treated immutably, then filtering null values is a convenient way to handle optional values. ECJ20260312 + kvPairs.filter { it.second != null } val requestBodyString = kvPairs.joinToString("&") { (key, value) -> "$key=$value" } val mediaType = CONTENT_TYPE_VALUE_HTTP_POST.toMediaTypeOrNull() return requestBodyString.toRequestBody(mediaType) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java index 7b98a22e48..ee7141dff4 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java @@ -104,6 +104,9 @@ public class OAuth2 { private static final String HYBRID_REFRESH = "hybrid_refresh"; // Grant Type Values public static final String LOGIN_HINT = "login_hint"; private static final String REFRESH_TOKEN = "refresh_token"; // Grant Type Values + + /// OAuth 2.0 authorization endpoint request body parameter names: Google Play Integrity API Token + protected static final String ATTESTATION = "attestation"; protected static final String RESPONSE_TYPE = "response_type"; private static final String SCOPE = "scope"; protected static final String REDIRECT_URI = "redirect_uri"; @@ -309,11 +312,17 @@ public static URI getAuthorizationUrl( String codeChallenge, Map addlParams) { final StringBuilder sb = new StringBuilder(loginServer.toString()); + + final String authorizationAttestationValue = SalesforceSDKManager.getInstance().testOAuthAuthorizationAttestationRequest(); + final String responseType = useWebServerAuthentication ? CODE : useHybridAuthentication ? HYBRID_TOKEN : TOKEN; sb.append(OAUTH_AUTH_PATH).append(getBrandedLoginPath()); sb.append(OAUTH_DISPLAY_PARAM).append(displayType == null ? TOUCH : displayType); + if (authorizationAttestationValue != null) { + sb.append(AND).append(ATTESTATION).append(EQUAL).append(authorizationAttestationValue); + } sb.append(AND).append(RESPONSE_TYPE).append(EQUAL).append(responseType); sb.append(AND).append(CLIENT_ID).append(EQUAL).append(Uri.encode(clientId)); if (scopes != null && scopes.length > 0) { @@ -541,9 +550,17 @@ private static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAcc URI loginServer, FormBody.Builder formBodyBuilder) throws OAuthFailedException, IOException { + + final String authorizationAttestationValue = SalesforceSDKManager.getInstance().testOAuthAuthorizationAttestationRequest(); + final StringBuilder sb = new StringBuilder(loginServer.toString()); sb.append(OAUTH_TOKEN_PATH); sb.append(QUESTION).append(DEVICE_ID).append(EQUAL).append(SalesforceSDKManager.getInstance().getDeviceId()); + + if (authorizationAttestationValue != null) { + sb.append(AND).append(ATTESTATION).append(EQUAL).append(authorizationAttestationValue); + } + final String refreshPath = sb.toString(); final RequestBody body = formBodyBuilder.build(); final Request request = new Request.Builder().url(refreshPath).post(body).build(); diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/AppAttestationChallengeApiClient.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/AppAttestationChallengeApiClient.kt new file mode 100644 index 0000000000..2704f5c8f1 --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/AppAttestationChallengeApiClient.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.androidsdk.rest + +import com.salesforce.androidsdk.rest.RestRequest.RestMethod.GET + +/** + * Provides REST client methods for the Salesforce Mobile App Attestation + * Challenge API endpoint. + * + * See https://docs.google.com/document/d/1MGw0-dO4Q-CJLNuqYBSYKAbEUy484lpLLX20ZIvwU6Y/edit?tab=t.0 + * TODO: Replace with final documentation when available. ECJ20260311 + * + * @param apiHostName The Salesforce `sfap_api` hostname + * @param restClient The REST client to use + */ +@Suppress("unused") +internal class AppAttestationChallengeApiClient( + private val apiHostName: String, + private val restClient: RestClient +) { + + /** + * Submit a request to the Salesforce Mobile App Attestation Challenge API + * `/mobile/attest/challenge` endpoint. + * @param attestationId The request's attestation id, which is intended to + * be the mobile device id + * @param remoteConsumerKey The Salesforce Mobile External Client App's + * Remote Consumer Key + * @return The API's "challenge", which is intended to be used as the Google + * Play Integrity API's request hash + */ + @Suppress("unused") + @Throws(SfapApiException::class) + fun fetchChallenge( + attestationId: String, + remoteConsumerKey: String + ): String { + + // Submit the request. + val restRequest = RestRequest( + GET, + "https://$apiHostName//mobile/attest/challenge?attestationId=$attestationId&consumerKey=$remoteConsumerKey" + ) + val restResponse = restClient.sendSync(restRequest) + val responseBodyString = restResponse.asString() + return if (restResponse.isSuccess && responseBodyString != null) { + responseBodyString + } else { + runCatching { + val errorResponseBody = SfapApiErrorResponseBody.fromJson(responseBodyString) + throw AppAttestationChallengeApiException( + message = errorResponseBody.message ?: "The server did not provide a message.", + source = errorResponseBody.sourceJson ?: "Source JSON could not be determined." + ) + }.getOrElse { + throw AppAttestationChallengeApiException( + message = "The server returned an unrecognized error response.", + source = responseBodyString + ) + } + } + } +} diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/AppAttestationChallengeApiException.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/AppAttestationChallengeApiException.kt new file mode 100644 index 0000000000..80422cca45 --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/AppAttestationChallengeApiException.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.androidsdk.rest + +/** + * An exception derived from a Mobile App Attestation Challenge API endpoint + * failure response. + * See https://docs.google.com/document/d/1MGw0-dO4Q-CJLNuqYBSYKAbEUy484lpLLX20ZIvwU6Y/edit?tab=t.0 + * TODO: Replace with final documentation when available. ECJ20260311 + * TODO: Determine actual properties when final documentation is available. ECJ20260311 + * @param message The `sfap_api` error message + * @param source The original `sfap_api` error response body + */ +class AppAttestationChallengeApiException( + message: String, + val source: String +) : Exception(message)