Skip to content
Draft
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
1 change: 1 addition & 0 deletions libs/SalesforceSDK/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Copy link
Owner Author

Choose a reason for hiding this comment

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

This SalesforceSDKManager.testGooglePlayIntegrityApiPreparation method will not exist in the production version. It's only present to support this rough proof-of-concept test.

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) }
Copy link
Owner Author

Choose a reason for hiding this comment

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

We spent some time today getting the correct SHA256 hashed hex string that exactly matches the Google Guava Hasher version that the backend is expecting. This snippet was aided by our agent tools.


// 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -238,7 +244,9 @@ internal class NativeLoginManager(
}
}

private fun createRequestBody(vararg kvPairs: Pair<String, String>): RequestBody {
private fun createRequestBody(vararg kvPairs: Pair<String, String?>): 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)
Expand Down
17 changes: 17 additions & 0 deletions libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -309,11 +312,17 @@ public static URI getAuthorizationUrl(
String codeChallenge,
Map<String,String> 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) {
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Owner Author

Choose a reason for hiding this comment

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

This new AppAttestationChallengeApiClient is modeled on the earlier stand-alone REST clients such as SfapApiClient. This new API only has one endpoint and its documentation is not finished.

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
)
}
}
}
}
Loading
Loading