Skip to content

Commit 082b018

Browse files
@W-21146662: [Android] App attestation integration testing (Initial Proof-Of-Concept)
1 parent a6fd61c commit 082b018

6 files changed

Lines changed: 289 additions & 5 deletions

File tree

libs/SalesforceSDK/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ dependencies {
2323
api("androidx.browser:browser:1.8.0") // Update requires API 36 compileSdk
2424
api("androidx.work:work-runtime-ktx:2.10.3")
2525

26+
implementation("com.google.android.play:integrity:1.6.0")
2627
implementation("com.google.accompanist:accompanist-drawablepainter:0.37.3")
2728
implementation("com.google.android.material:material:1.13.0") // remove this when all xml is gone
2829
implementation("androidx.appcompat:appcompat:1.7.1")

libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import android.provider.Settings.Secure.ANDROID_ID
5252
import android.provider.Settings.Secure.getString
5353
import android.text.TextUtils.isEmpty
5454
import android.text.TextUtils.join
55+
import android.util.Log
5556
import android.view.View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
5657
import android.view.View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
5758
import android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
@@ -71,6 +72,10 @@ import androidx.window.core.layout.WindowHeightSizeClass
7172
import androidx.window.core.layout.WindowSizeClass
7273
import androidx.window.core.layout.WindowWidthSizeClass
7374
import androidx.window.layout.WindowMetricsCalculator
75+
import com.google.android.play.core.integrity.IntegrityManagerFactory.createStandard
76+
import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest
77+
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider
78+
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest
7479
import com.salesforce.androidsdk.BuildConfig.DEBUG
7580
import com.salesforce.androidsdk.R.string.account_type
7681
import com.salesforce.androidsdk.R.string.sf__dev_support_title
@@ -123,6 +128,7 @@ import com.salesforce.androidsdk.push.PushNotificationInterface
123128
import com.salesforce.androidsdk.push.PushService
124129
import com.salesforce.androidsdk.push.PushService.Companion.pushNotificationsRegistrationType
125130
import com.salesforce.androidsdk.push.PushService.PushNotificationReRegistrationType.ReRegistrationOnAppForeground
131+
import com.salesforce.androidsdk.rest.AppAttestationChallengeApiClient
126132
import com.salesforce.androidsdk.rest.ClientManager
127133
import com.salesforce.androidsdk.rest.NotificationsActionsResponseBody
128134
import com.salesforce.androidsdk.rest.NotificationsApiClient
@@ -148,10 +154,17 @@ import kotlinx.coroutines.CoroutineScope
148154
import kotlinx.coroutines.Dispatchers.Default
149155
import kotlinx.coroutines.Dispatchers.Main
150156
import kotlinx.coroutines.launch
157+
import kotlinx.coroutines.runBlocking
158+
import kotlinx.coroutines.tasks.await
151159
import kotlinx.coroutines.withTimeoutOrNull
160+
import kotlinx.serialization.Serializable
161+
import kotlinx.serialization.json.Json
152162
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
153163
import java.lang.String.CASE_INSENSITIVE_ORDER
154164
import java.net.URI
165+
import java.nio.charset.StandardCharsets.UTF_8
166+
import java.security.MessageDigest
167+
import java.util.Base64
155168
import java.util.Locale.US
156169
import java.util.SortedSet
157170
import java.util.UUID.randomUUID
@@ -683,6 +696,122 @@ open class SalesforceSDKManager protected constructor(
683696
)
684697
}
685698

699+
/** The Google Play Integrity API Token Provider */
700+
private var integrityTokenProvider: StandardIntegrityTokenProvider? = null
701+
702+
/**
703+
* A simple proof-of-concept to prepare for authorization and authorization
704+
* token refresh with the Salesforce Mobile App Attestation Challenge API
705+
* and Google Play Integrity API enabled.
706+
*
707+
* TODO: This will need to be made production-ready in the future. ECJ20260312
708+
// TODO: Discuss a suitable scope for this as attaching it to this singleton may further legacy patterns. ECJ20260312
709+
*/
710+
fun testGooglePlayIntegrityApiPreparation() {
711+
CoroutineScope(Default).launch {
712+
// The app's corresponding Cloud Project Number.
713+
// TODO: Determine where this value would be provided to or by Salesforce Mobile SDK. ECJ20260311
714+
val cloudProjectNumber = -1L // TODO: Google Cloud Project Number. ECJ20260311
715+
716+
// 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
717+
718+
// Create the Google Play Integrity Manager and Token Provider.
719+
val integrityManager = createStandard(this@SalesforceSDKManager.appContext)
720+
721+
// Prepare the Google Play Integrity token. Calling this prior to requesting the Integrity Token reduces the latency of the request.
722+
integrityManager.prepareIntegrityToken(
723+
PrepareIntegrityTokenRequest.builder()
724+
.setCloudProjectNumber(cloudProjectNumber)
725+
.build()
726+
).addOnSuccessListener { tokenProvider ->
727+
integrityTokenProvider = tokenProvider
728+
Log.i("AppAttestation", "Prepared Google Play Integrity Token Provider: '${tokenProvider}'.")
729+
}.addOnFailureListener { exception ->
730+
Log.e("AppAttestation", "Failed to prepare Google Play Integrity Token Provider: '${exception.message}'.")
731+
}
732+
}
733+
}
734+
735+
/**
736+
* A simple proof-of-concept for fetching the Salesforce App Attestation
737+
* ECA Plug-In's "Challenge".
738+
* @return The Salesforce App Attestation ECA Plug-In's "Challenge"
739+
*/
740+
internal fun testSalesforceMobileAppAttestationChallengeRequest(): String {
741+
// Create the Salesforce App Attestation Challenge API client and fetch a new challenge.
742+
val appAttestationChallengeApiClient = AppAttestationChallengeApiClient(
743+
apiHostName = "msdkappattestationtestorg.test1.my.pc-rnd.salesforce.com", // TODO: Replace with template placeholder. ECJ20260311
744+
restClient = clientManager.peekUnauthenticatedRestClient()
745+
)
746+
val salesforceAppAttestationChallenge = appAttestationChallengeApiClient.fetchChallenge(
747+
attestationId = deviceId,
748+
remoteConsumerKey = getBootConfig(this@SalesforceSDKManager.appContext).remoteAccessConsumerKey
749+
)
750+
751+
return salesforceAppAttestationChallenge
752+
}
753+
754+
/**
755+
* A simple proof-of-concept for fetching a Google Play Integrity API Token
756+
* using the Salesforce App Attestation ECA Plug-In's "Challenge" as the
757+
* Request Hash.
758+
* @return The Google Play Integrity API Token
759+
*/
760+
fun testOAuthAuthorizationAttestationRequest(): String? {
761+
// Guards.
762+
val integrityTokenProvider = integrityTokenProvider ?: return null
763+
764+
// Fetch the Salesforce Mobile App Attestation Challenge.
765+
val salesforceAppAttestationChallenge = testSalesforceMobileAppAttestationChallengeRequest()
766+
val salesforceAppAttestationChallengeHashByteArray = MessageDigest.getInstance("SHA-256")
767+
.digest(salesforceAppAttestationChallenge.toByteArray(UTF_8))
768+
val salesforceAppAttestationChallengeHashHexString = salesforceAppAttestationChallengeHashByteArray.joinToString("") { "%02x".format(it) }
769+
770+
// Request the Google Play Integrity Token.
771+
val integrityTokenResponse = integrityTokenProvider.request(
772+
StandardIntegrityTokenRequest.builder()
773+
.setRequestHash(salesforceAppAttestationChallengeHashHexString)
774+
.build()
775+
)
776+
val googlePlayIntegrityTask = integrityTokenResponse.addOnSuccessListener { response ->
777+
Log.i("AppAttestation", "Received Google Play Integrity Token: '${response.token()}'.")
778+
779+
}.addOnFailureListener { exception ->
780+
// 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.
781+
Log.e("AppAttestation", "Failed To Receive Google Play Integrity Token: Message: '${exception.message}'.")
782+
783+
// TODO: Handle the error by requesting a new Google Play Integrity Token Provider. ECJ20260311
784+
}
785+
786+
// Wait for the Google Play Integrity API response and return the Base64-encoded Salesforce OAuth authorization attestation parameter JSON.
787+
runBlocking {
788+
googlePlayIntegrityTask.await()
789+
}
790+
return OAuthAuthorizationAttestation(
791+
attestationId = deviceId,
792+
integrityToken = googlePlayIntegrityTask.getResult().token()
793+
).toBase64String()
794+
}
795+
796+
/**
797+
* A Salesforce OAuth 2.0 authorization "attestation" parameter.
798+
* @param attestationId The attestation id used when creating the Salesforce
799+
* Mobile App Attestation API Challenge. This is intended to be the
800+
* Salesforce Mobile SDK device id
801+
* @param integrityToken The token provided by the Google Play Integrity API
802+
*/
803+
@Serializable
804+
internal data class OAuthAuthorizationAttestation(
805+
val attestationId: String,
806+
val integrityToken: String,
807+
) {
808+
809+
/**
810+
* Returns a Base64-encoded JSON representation of this object
811+
*/
812+
fun toBase64String(): String? = Base64.getEncoder().encodeToString(Json.encodeToString(serializer(), this).encodeToByteArray())
813+
}
814+
686815
/**
687816
* Optionally enables browser based login instead of web view login.
688817
*

libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ import android.util.Base64.encodeToString
3737
import android.util.Patterns.EMAIL_ADDRESS
3838
import androidx.core.os.bundleOf
3939
import com.salesforce.androidsdk.app.SalesforceSDKManager
40+
import com.salesforce.androidsdk.app.SalesforceSDKManager.Companion.getInstance
4041
import com.salesforce.androidsdk.auth.NativeLoginManager.StartRegistrationRequestBody.UserData
42+
import com.salesforce.androidsdk.auth.OAuth2.ATTESTATION
4143
import com.salesforce.androidsdk.auth.OAuth2.AUTHORIZATION
4244
import com.salesforce.androidsdk.auth.OAuth2.AUTHORIZATION_CODE
4345
import com.salesforce.androidsdk.auth.OAuth2.CLIENT_ID
@@ -143,7 +145,9 @@ internal class NativeLoginManager(
143145
CONTENT_TYPE_HEADER_NAME to CONTENT_TYPE_VALUE_HTTP_POST,
144146
AUTHORIZATION to "$AUTH_AUTHORIZATION_VALUE_BASIC $encodedCreds",
145147
)
148+
val authorizationAttestationValue = getInstance().testOAuthAuthorizationAttestationRequest()
146149
val authRequestBody = createRequestBody(
150+
ATTESTATION to authorizationAttestationValue,
147151
RESPONSE_TYPE to CODE_CREDENTIALS,
148152
CLIENT_ID to clientId,
149153
REDIRECT_URI to redirectUri,
@@ -186,11 +190,13 @@ internal class NativeLoginManager(
186190

187191
@VisibleForTesting
188192
internal fun isValidPassword(password: String): Boolean {
189-
val containsNumber = password.contains("[0-9]".toRegex())
190-
val containsLetter = password.contains("[A-Za-z]".toRegex())
193+
// TODO: Revert this change after testing with administrator-created accounts that have non-compliant passwords. ECJ20260312
194+
// val containsNumber = password.contains("[0-9]".toRegex())
195+
// val containsLetter = password.contains("[A-Za-z]".toRegex())
191196

192-
return containsNumber && containsLetter && password.length >= MIN_PASSWORD_LENGTH
193-
&& password.toByteArray().size <= MAX_PASSWORD_LENGTH_BYTES
197+
// return containsNumber && containsLetter && password.length >= MIN_PASSWORD_LENGTH
198+
// && password.toByteArray().size <= MAX_PASSWORD_LENGTH_BYTES
199+
return true
194200
}
195201

196202
private suspend fun suspendFinishAuthFlow(tokenResponse: RestResponse): NativeLoginResult {
@@ -238,7 +244,9 @@ internal class NativeLoginManager(
238244
}
239245
}
240246

241-
private fun createRequestBody(vararg kvPairs: Pair<String, String>): RequestBody {
247+
private fun createRequestBody(vararg kvPairs: Pair<String, String?>): RequestBody {
248+
// TODO: Review this. If the request body is treated immutably, then filtering null values is a convenient way to handle optional values. ECJ20260312
249+
kvPairs.filter { it.second != null }
242250
val requestBodyString = kvPairs.joinToString("&") { (key, value) -> "$key=$value" }
243251
val mediaType = CONTENT_TYPE_VALUE_HTTP_POST.toMediaTypeOrNull()
244252
return requestBodyString.toRequestBody(mediaType)

libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ public class OAuth2 {
104104
private static final String HYBRID_REFRESH = "hybrid_refresh"; // Grant Type Values
105105
public static final String LOGIN_HINT = "login_hint";
106106
private static final String REFRESH_TOKEN = "refresh_token"; // Grant Type Values
107+
108+
/// OAuth 2.0 authorization endpoint request body parameter names: Google Play Integrity API Token
109+
protected static final String ATTESTATION = "attestation";
107110
protected static final String RESPONSE_TYPE = "response_type";
108111
private static final String SCOPE = "scope";
109112
protected static final String REDIRECT_URI = "redirect_uri";
@@ -309,11 +312,17 @@ public static URI getAuthorizationUrl(
309312
String codeChallenge,
310313
Map<String,String> addlParams) {
311314
final StringBuilder sb = new StringBuilder(loginServer.toString());
315+
316+
final String authorizationAttestationValue = SalesforceSDKManager.getInstance().testOAuthAuthorizationAttestationRequest();
317+
312318
final String responseType = useWebServerAuthentication
313319
? CODE
314320
: useHybridAuthentication ? HYBRID_TOKEN : TOKEN;
315321
sb.append(OAUTH_AUTH_PATH).append(getBrandedLoginPath());
316322
sb.append(OAUTH_DISPLAY_PARAM).append(displayType == null ? TOUCH : displayType);
323+
if (authorizationAttestationValue != null) {
324+
sb.append(AND).append(ATTESTATION).append(EQUAL).append(authorizationAttestationValue);
325+
}
317326
sb.append(AND).append(RESPONSE_TYPE).append(EQUAL).append(responseType);
318327
sb.append(AND).append(CLIENT_ID).append(EQUAL).append(Uri.encode(clientId));
319328
if (scopes != null && scopes.length > 0) {
@@ -541,9 +550,17 @@ private static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAcc
541550
URI loginServer,
542551
FormBody.Builder formBodyBuilder)
543552
throws OAuthFailedException, IOException {
553+
554+
final String authorizationAttestationValue = SalesforceSDKManager.getInstance().testOAuthAuthorizationAttestationRequest();
555+
544556
final StringBuilder sb = new StringBuilder(loginServer.toString());
545557
sb.append(OAUTH_TOKEN_PATH);
546558
sb.append(QUESTION).append(DEVICE_ID).append(EQUAL).append(SalesforceSDKManager.getInstance().getDeviceId());
559+
560+
if (authorizationAttestationValue != null) {
561+
sb.append(AND).append(ATTESTATION).append(EQUAL).append(authorizationAttestationValue);
562+
}
563+
547564
final String refreshPath = sb.toString();
548565
final RequestBody body = formBodyBuilder.build();
549566
final Request request = new Request.Builder().url(refreshPath).post(body).build();
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright (c) 2026-present, salesforce.com, inc.
3+
* All rights reserved.
4+
* Redistribution and use of this software in source and binary forms, with or
5+
* without modification, are permitted provided that the following conditions
6+
* are met:
7+
* - Redistributions of source code must retain the above copyright notice, this
8+
* list of conditions and the following disclaimer.
9+
* - Redistributions in binary form must reproduce the above copyright notice,
10+
* this list of conditions and the following disclaimer in the documentation
11+
* and/or other materials provided with the distribution.
12+
* - Neither the name of salesforce.com, inc. nor the names of its contributors
13+
* may be used to endorse or promote products derived from this software without
14+
* specific prior written permission of salesforce.com, inc.
15+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
18+
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
19+
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
20+
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
21+
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
22+
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
23+
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
24+
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
25+
* POSSIBILITY OF SUCH DAMAGE.
26+
*/
27+
package com.salesforce.androidsdk.rest
28+
29+
import com.salesforce.androidsdk.rest.RestRequest.RestMethod.GET
30+
31+
/**
32+
* Provides REST client methods for the Salesforce Mobile App Attestation
33+
* Challenge API endpoint.
34+
*
35+
* See https://docs.google.com/document/d/1MGw0-dO4Q-CJLNuqYBSYKAbEUy484lpLLX20ZIvwU6Y/edit?tab=t.0
36+
* TODO: Replace with final documentation when available. ECJ20260311
37+
*
38+
* @param apiHostName The Salesforce `sfap_api` hostname
39+
* @param restClient The REST client to use
40+
*/
41+
@Suppress("unused")
42+
internal class AppAttestationChallengeApiClient(
43+
private val apiHostName: String,
44+
private val restClient: RestClient
45+
) {
46+
47+
/**
48+
* Submit a request to the Salesforce Mobile App Attestation Challenge API
49+
* `/mobile/attest/challenge` endpoint.
50+
* @param attestationId The request's attestation id, which is intended to
51+
* be the mobile device id
52+
* @param remoteConsumerKey The Salesforce Mobile External Client App's
53+
* Remote Consumer Key
54+
* @return The API's "challenge", which is intended to be used as the Google
55+
* Play Integrity API's request hash
56+
*/
57+
@Suppress("unused")
58+
@Throws(SfapApiException::class)
59+
fun fetchChallenge(
60+
attestationId: String,
61+
remoteConsumerKey: String
62+
): String {
63+
64+
// Submit the request.
65+
val restRequest = RestRequest(
66+
GET,
67+
"https://$apiHostName//mobile/attest/challenge?attestationId=$attestationId&consumerKey=$remoteConsumerKey"
68+
)
69+
val restResponse = restClient.sendSync(restRequest)
70+
val responseBodyString = restResponse.asString()
71+
return if (restResponse.isSuccess && responseBodyString != null) {
72+
responseBodyString
73+
} else {
74+
runCatching {
75+
val errorResponseBody = SfapApiErrorResponseBody.fromJson(responseBodyString)
76+
throw AppAttestationChallengeApiException(
77+
message = errorResponseBody.message ?: "The server did not provide a message.",
78+
source = errorResponseBody.sourceJson ?: "Source JSON could not be determined."
79+
)
80+
}.getOrElse {
81+
throw AppAttestationChallengeApiException(
82+
message = "The server returned an unrecognized error response.",
83+
source = responseBodyString
84+
)
85+
}
86+
}
87+
}
88+
}

0 commit comments

Comments
 (0)