Skip to content

Commit 3b19ac6

Browse files
@W-21933885: [MSDK Android] App Attestation Implementation (#2868)
1 parent 7d0dc86 commit 3b19ac6

18 files changed

Lines changed: 2174 additions & 40 deletions

libs/SalesforceSDK/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dependencies {
2424
api("androidx.browser:browser:1.10.0")
2525
api("androidx.work:work-runtime-ktx:2.11.2")
2626

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

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import com.salesforce.androidsdk.app.Features.FEATURE_BROWSER_LOGIN
8989
import com.salesforce.androidsdk.app.Features.FEATURE_NATIVE_LOGIN
9090
import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.DARK
9191
import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.SYSTEM_DEFAULT
92+
import com.salesforce.androidsdk.auth.AppAttestationClient
9293
import com.salesforce.androidsdk.auth.AuthenticatorService.KEY_INSTANCE_URL
9394
import com.salesforce.androidsdk.auth.HttpAccess
9495
import com.salesforce.androidsdk.auth.HttpAccess.DEFAULT
@@ -226,6 +227,54 @@ open class SalesforceSDKManager protected constructor(
226227
*/
227228
val loginActivityClass: Class<out Activity> = nativeLoginActivity ?: webViewLoginActivityClass
228229

230+
/**
231+
* The client side implementation of the Salesforce App Attestation External
232+
* Client App (ECA) Plugin or null when app attestation is disabled.
233+
*
234+
* This property is not intended for public use outside of Salesforce Mobile
235+
* SDK
236+
*
237+
* TODO: Make this Kotlin-internal once it is no longer referenced by Java. ECJ20260420
238+
*/
239+
@Volatile
240+
var appAttestationClient: AppAttestationClient? = null
241+
@VisibleForTesting
242+
internal set
243+
244+
/** Lock object for synchronized access to the app Attestation Client */
245+
private val appAttestationClientLock = Any()
246+
247+
/**
248+
* Updates the Salesforce App Attestation ECA Plugin Client for the selected
249+
* login server and matching Google Cloud Project ID. When using App
250+
* Attestation, this value must match the linked Google Cloud Project ID
251+
* for the app in Google Play Console's Play Integrity API and provided to
252+
* the Salesforce App Attestation External Client App Plugin.
253+
*
254+
* @param apiHostName The Salesforce App Attestation External Client App
255+
* (ECA) Plugin Challenge API Host Name. This usually matches the selected
256+
* login server
257+
* @param googleCloudProjectId The Google Cloud Project ID or null to
258+
* disable Salesforce App Attestation
259+
*/
260+
fun updateAppAttestationClient(
261+
apiHostName: String,
262+
googleCloudProjectId: Long? = null
263+
) {
264+
synchronized(appAttestationClientLock) {
265+
appAttestationClient = googleCloudProjectId?.let { appAttestationGoogleCloudProjectId ->
266+
AppAttestationClient(
267+
context = appContext,
268+
apiHostName = apiHostName,
269+
deviceId = deviceId,
270+
googleCloudProjectId = appAttestationGoogleCloudProjectId,
271+
remoteAccessConsumerKey = getBootConfig(appContext).remoteAccessConsumerKey,
272+
restClient = clientManager.peekUnauthenticatedRestClient()
273+
)
274+
}
275+
}
276+
}
277+
229278
/**
230279
* ViewModel Factory the SDK will use in LoginActivity and composable functions. Setting this will allow for
231280
* visual customization without overriding LoginActivity.
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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.auth
28+
29+
import android.content.Context
30+
import androidx.annotation.VisibleForTesting
31+
import com.google.android.play.core.integrity.IntegrityManagerFactory.createStandard
32+
import com.google.android.play.core.integrity.IntegrityServiceException
33+
import com.google.android.play.core.integrity.StandardIntegrityManager
34+
import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest
35+
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider
36+
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest
37+
import com.google.android.play.core.integrity.model.StandardIntegrityErrorCode.INTEGRITY_TOKEN_PROVIDER_INVALID
38+
import com.salesforce.androidsdk.rest.AppAttestationChallengeApiClient
39+
import com.salesforce.androidsdk.rest.RestClient
40+
import com.salesforce.androidsdk.util.SalesforceSDKLogger.w
41+
import kotlinx.coroutines.runBlocking
42+
import kotlinx.coroutines.tasks.await
43+
import kotlinx.serialization.Serializable
44+
import kotlinx.serialization.json.Json
45+
import java.nio.charset.StandardCharsets.UTF_8
46+
import java.security.MessageDigest
47+
import java.util.Base64
48+
49+
/**
50+
* App attestation features supporting the Salesforce App Attestation External
51+
* Client App (ECA) Plugin, the Salesforce Challenge API, Google Play Integrity
52+
* API and integration of app attestation with Salesforce Authentication.
53+
*
54+
* This method is not intended for public use outside of Salesforce Mobile SDK.
55+
*
56+
* TODO: Make this class internal once Java support is removed. ECJ20260421
57+
*
58+
* @param apiHostName The Salesforce App Attestation Challenge API host
59+
* @param deviceId The device id, usually provided by the Salesforce SDK Manager
60+
* @param googleCloudProjectId The Google Cloud Project ID used with Google Play
61+
* Integrity API
62+
* @param integrityManager The Google Play App Integrity API Integrity Manager.
63+
* This parameter is intended for testing purposes only. Defaults to a new
64+
* instance
65+
* @param remoteAccessConsumerKey The Salesforce Connected App (CA) or External
66+
* Client App (ECA)remote access consumer key, usually provided by the boot
67+
* config
68+
* @param restClient The REST client, usually provided by the Salesforce SDK
69+
* Manager's unauthenticated REST client
70+
*/
71+
class AppAttestationClient(
72+
context: Context,
73+
@property:VisibleForTesting
74+
internal val apiHostName: String,
75+
@property:VisibleForTesting
76+
internal val deviceId: String,
77+
@property:VisibleForTesting
78+
internal val googleCloudProjectId: Long,
79+
@property:VisibleForTesting
80+
internal val integrityManager: StandardIntegrityManager = createStandard(context),
81+
@property:VisibleForTesting
82+
internal val remoteAccessConsumerKey: String,
83+
@property:VisibleForTesting
84+
internal val restClient: RestClient,
85+
) {
86+
87+
88+
/** The Google Play Integrity API Token Provider */
89+
@VisibleForTesting
90+
internal var integrityTokenProvider: StandardIntegrityTokenProvider? = null
91+
92+
init {
93+
prepareIntegrityTokenProvider()
94+
}
95+
96+
/**
97+
* (Re-)prepares the Google Play Integrity Token Provider. Calling this
98+
* prior to requesting the Integrity Token via
99+
* [createAppAttestation] reduces the latency of the request.
100+
*/
101+
@VisibleForTesting
102+
internal fun prepareIntegrityTokenProvider() = integrityManager.prepareIntegrityToken(
103+
PrepareIntegrityTokenRequest.builder()
104+
.setCloudProjectNumber(googleCloudProjectId)
105+
.build()
106+
).addOnSuccessListener(
107+
::onPrepareIntegrityTokenProviderSuccess
108+
).addOnFailureListener(
109+
::onPrepareIntegrityTokenProviderFailure
110+
)
111+
112+
/**
113+
* A success callback used by [prepareIntegrityTokenProvider].
114+
* @param tokenProvider The Google Play API Integrity Token Provider
115+
*/
116+
@VisibleForTesting
117+
internal fun onPrepareIntegrityTokenProviderSuccess(tokenProvider: StandardIntegrityTokenProvider) {
118+
integrityTokenProvider = tokenProvider
119+
}
120+
121+
/**
122+
* A failure callback for [prepareIntegrityTokenProvider].
123+
* @param exception The exception provided by Google Play Integrity API
124+
*/
125+
@VisibleForTesting
126+
internal fun onPrepareIntegrityTokenProviderFailure(exception: Exception) {
127+
w(javaClass.name, "Failed to prepare Google Play Integrity Token Provider: '${exception.message}'. App Attestation will be disabled.")
128+
}
129+
130+
/**
131+
* Creates a Salesforce App Attestation External Client App (ECA) Plugin
132+
* "attestation". First a Salesforce Mobile App Attestation "Challenge" is
133+
* requested for the device id. Then, a Google Play Integrity API Token is
134+
* fetched using the "Challenge" as the Request Hash. The resulting token is
135+
* encoded into a value usable as the "attestation" parameter in the
136+
* Salesforce OAuth authorization request.
137+
*
138+
* This method is not intended for public use outside of Salesforce Mobile
139+
* SDK.
140+
*
141+
* TODO: Make this Kotlin-internal once it is no longer referenced by Java. ECJ20260420
142+
*
143+
* @param appAttestationChallenge The Salesforce Mobile App Attestation
144+
* External Client App (ECA) Plug-In "Challenge" to use
145+
* @param integrityTokenProvider The Google Play App Integrity API Integrity
146+
* Token Provider. This parameter is intended for testing purposes only
147+
* @param canRetryOnInvalidTokenProvider When true (the default), a single
148+
* inline retry with a freshly prepared Integrity Token Provider is allowed
149+
* if the request fails with [INTEGRITY_TOKEN_PROVIDER_INVALID]. The
150+
* recursive retry call sets this false to guarantee at most one retry
151+
* and prevent unbounded recursion on the caller thread
152+
* @return The "attestation" value usable in Salesforce OAuth authorization
153+
* and token refresh requests or null if the value cannot be created
154+
*/
155+
suspend fun createAppAttestation(
156+
appAttestationChallenge: String,
157+
integrityTokenProvider: StandardIntegrityTokenProvider? = this.integrityTokenProvider,
158+
canRetryOnInvalidTokenProvider: Boolean = true,
159+
): String? {
160+
// Guard to ensure the Google Play Integrity API Integrity Provider was asynchronously resolved or do so synchronously now.
161+
val integrityTokenProviderResolved = integrityTokenProvider ?: prepareIntegrityTokenProvider().await()
162+
163+
// Fetch the Challenge from Salesforce Mobile App Attestation.
164+
val salesforceAppAttestationChallengeHashByteArray = MessageDigest.getInstance("SHA-256")
165+
.digest(appAttestationChallenge.toByteArray(UTF_8))
166+
val salesforceAppAttestationChallengeHashHexString = salesforceAppAttestationChallengeHashByteArray.joinToString("") { "%02x".format(it) }
167+
168+
// Request the Google Play Integrity Token.
169+
val integrityTokenResponse = integrityTokenProviderResolved.request(
170+
StandardIntegrityTokenRequest.builder()
171+
.setRequestHash(salesforceAppAttestationChallengeHashHexString)
172+
.build()
173+
)
174+
175+
/*
176+
* Wait for the Google Play Integrity API response and return the
177+
* Base64-encoded Salesforce OAuth authorization attestation parameter
178+
* JSON. This may block the calling thread if the Google Play Integrity
179+
* API introduces latency, though latency is expected to minimal as the
180+
* API will have been prepared earlier in most scenarios.
181+
*/
182+
return runCatching {
183+
integrityTokenResponse.await()
184+
185+
// When the Google Play Integrity API response is received, return the Base64-encoded Salesforce OAuth authorization attestation parameter JSON.
186+
OAuthAuthorizationAttestation(
187+
attestationId = deviceId,
188+
attestationData = Base64.getEncoder().encodeToString(
189+
integrityTokenResponse.getResult().token().encodeToByteArray()
190+
)
191+
).toBase64String()
192+
}.getOrElse { e ->
193+
// If the Google Play Integrity API failed due to the Integrity Token Provider being expired, re-prepare it once for an inline retry.
194+
// The retry call passes canRetryOnInvalidTokenProvider = false to cap retries at one attempt and prevent unbounded recursion on the caller thread if the freshly prepared provider also reports INTEGRITY_TOKEN_PROVIDER_INVALID.
195+
if (canRetryOnInvalidTokenProvider && (e as? IntegrityServiceException)?.errorCode == INTEGRITY_TOKEN_PROVIDER_INVALID) {
196+
createAppAttestation(
197+
appAttestationChallenge = appAttestationChallenge,
198+
integrityTokenProvider = null,
199+
canRetryOnInvalidTokenProvider = false,
200+
)
201+
} else {
202+
null
203+
}
204+
}
205+
}
206+
207+
/**
208+
* A blocking Java-callable wrapper for [createAppAttestation]
209+
*
210+
* This method is not intended for public use outside of Salesforce Mobile
211+
* SDK.
212+
*
213+
* TODO: Remove method when no longer referenced by Java. ECJ20260420
214+
* @param appAttestationChallenge The Salesforce Mobile App Attestation
215+
* External Client App (ECA) Plug-In "Challenge" to use
216+
*/
217+
fun createAppAttestationBlocking(appAttestationChallenge: String) = runBlocking {
218+
createAppAttestation(appAttestationChallenge)
219+
}
220+
221+
/**
222+
* Fetches a new "Challenge" from the Salesforce App Attestation External
223+
* Client App (ECA) Plug-In.
224+
*
225+
* This method is not intended for public use outside of Salesforce Mobile
226+
* SDK.
227+
*
228+
* TODO: Make this Kotlin-internal once it is no longer referenced by Java. ECJ20260420
229+
*
230+
* @return The Salesforce App Attestation ECA Plug-In's "Challenge"
231+
*/
232+
fun fetchMobileAppAttestationChallenge(): String {
233+
// Create the Salesforce App Attestation Challenge API client and fetch a new challenge.
234+
val appAttestationChallengeApiClient = AppAttestationChallengeApiClient(
235+
apiHostName = apiHostName,
236+
restClient = restClient
237+
)
238+
return appAttestationChallengeApiClient.fetchChallenge(
239+
attestationId = deviceId,
240+
remoteConsumerKey = remoteAccessConsumerKey
241+
)
242+
}
243+
}
244+
245+
/**
246+
* A Salesforce OAuth 2.0 authorization "attestation" parameter.
247+
* @param attestationId The attestation id used when creating the Salesforce
248+
* Mobile App Attestation API Challenge. This is intended to be the
249+
* Salesforce Mobile SDK device id
250+
* @param attestationData The token provided by the Google Play Integrity API
251+
*/
252+
@Serializable
253+
internal data class OAuthAuthorizationAttestation(
254+
val attestationId: String,
255+
val attestationData: String,
256+
) {
257+
258+
/**
259+
* Returns a Base64-encoded JSON representation of this object.
260+
*
261+
* Note: Standard Base64 alphabet with padding is used by design. The
262+
* Salesforce App Attestation server-side contract requires the
263+
* standard (not URL-safe) Base64 encoding with padding, and the value
264+
* is consumed as-is without URI percent-encoding at the token endpoint
265+
* (see OAuth2.makeTokenEndpointRequest). This has been verified
266+
* end-to-end; do not switch to Base64.getUrlEncoder() or strip padding.
267+
*/
268+
fun toBase64String(): String? = Base64.getEncoder().encodeToString(Json.encodeToString(serializer(), this).encodeToByteArray())
269+
}
270+

0 commit comments

Comments
 (0)