-
Notifications
You must be signed in to change notification settings - Fork 0
@W-21146662: [Android] App attestation integration testing #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| // 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. | ||
| * | ||
|
|
||
| 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( | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This new |
||
| 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 | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This
SalesforceSDKManager.testGooglePlayIntegrityApiPreparationmethod will not exist in the production version. It's only present to support this rough proof-of-concept test.