diff --git a/libs/SalesforceSDK/build.gradle.kts b/libs/SalesforceSDK/build.gradle.kts index 1888542858..071c16534c 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 138d9b92ef..81f8f85462 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -89,6 +89,7 @@ import com.salesforce.androidsdk.app.Features.FEATURE_BROWSER_LOGIN import com.salesforce.androidsdk.app.Features.FEATURE_NATIVE_LOGIN import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.DARK import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.SYSTEM_DEFAULT +import com.salesforce.androidsdk.auth.AppAttestationClient import com.salesforce.androidsdk.auth.AuthenticatorService.KEY_INSTANCE_URL import com.salesforce.androidsdk.auth.HttpAccess import com.salesforce.androidsdk.auth.HttpAccess.DEFAULT @@ -226,6 +227,54 @@ open class SalesforceSDKManager protected constructor( */ val loginActivityClass: Class = nativeLoginActivity ?: webViewLoginActivityClass + /** + * The client side implementation of the Salesforce App Attestation External + * Client App (ECA) Plugin or null when app attestation is disabled. + * + * This property is not intended for public use outside of Salesforce Mobile + * SDK + * + * TODO: Make this Kotlin-internal once it is no longer referenced by Java. ECJ20260420 + */ + @Volatile + var appAttestationClient: AppAttestationClient? = null + @VisibleForTesting + internal set + + /** Lock object for synchronized access to the app Attestation Client */ + private val appAttestationClientLock = Any() + + /** + * Updates the Salesforce App Attestation ECA Plugin Client for the selected + * login server and matching Google Cloud Project ID. When using App + * Attestation, this value must match the linked Google Cloud Project ID + * for the app in Google Play Console's Play Integrity API and provided to + * the Salesforce App Attestation External Client App Plugin. + * + * @param apiHostName The Salesforce App Attestation External Client App + * (ECA) Plugin Challenge API Host Name. This usually matches the selected + * login server + * @param googleCloudProjectId The Google Cloud Project ID or null to + * disable Salesforce App Attestation + */ + fun updateAppAttestationClient( + apiHostName: String, + googleCloudProjectId: Long? = null + ) { + synchronized(appAttestationClientLock) { + appAttestationClient = googleCloudProjectId?.let { appAttestationGoogleCloudProjectId -> + AppAttestationClient( + context = appContext, + apiHostName = apiHostName, + deviceId = deviceId, + googleCloudProjectId = appAttestationGoogleCloudProjectId, + remoteAccessConsumerKey = getBootConfig(appContext).remoteAccessConsumerKey, + restClient = clientManager.peekUnauthenticatedRestClient() + ) + } + } + } + /** * ViewModel Factory the SDK will use in LoginActivity and composable functions. Setting this will allow for * visual customization without overriding LoginActivity. diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AppAttestationClient.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AppAttestationClient.kt new file mode 100644 index 0000000000..ca4d2061ac --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AppAttestationClient.kt @@ -0,0 +1,270 @@ +/* + * 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.auth + +import android.content.Context +import androidx.annotation.VisibleForTesting +import com.google.android.play.core.integrity.IntegrityManagerFactory.createStandard +import com.google.android.play.core.integrity.IntegrityServiceException +import com.google.android.play.core.integrity.StandardIntegrityManager +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.google.android.play.core.integrity.model.StandardIntegrityErrorCode.INTEGRITY_TOKEN_PROVIDER_INVALID +import com.salesforce.androidsdk.rest.AppAttestationChallengeApiClient +import com.salesforce.androidsdk.rest.RestClient +import com.salesforce.androidsdk.util.SalesforceSDKLogger.w +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.tasks.await +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.nio.charset.StandardCharsets.UTF_8 +import java.security.MessageDigest +import java.util.Base64 + +/** + * App attestation features supporting the Salesforce App Attestation External + * Client App (ECA) Plugin, the Salesforce Challenge API, Google Play Integrity + * API and integration of app attestation with Salesforce Authentication. + * + * This method is not intended for public use outside of Salesforce Mobile SDK. + * + * TODO: Make this class internal once Java support is removed. ECJ20260421 + * + * @param apiHostName The Salesforce App Attestation Challenge API host + * @param deviceId The device id, usually provided by the Salesforce SDK Manager + * @param googleCloudProjectId The Google Cloud Project ID used with Google Play + * Integrity API + * @param integrityManager The Google Play App Integrity API Integrity Manager. + * This parameter is intended for testing purposes only. Defaults to a new + * instance + * @param remoteAccessConsumerKey The Salesforce Connected App (CA) or External + * Client App (ECA)remote access consumer key, usually provided by the boot + * config + * @param restClient The REST client, usually provided by the Salesforce SDK + * Manager's unauthenticated REST client + */ +class AppAttestationClient( + context: Context, + @property:VisibleForTesting + internal val apiHostName: String, + @property:VisibleForTesting + internal val deviceId: String, + @property:VisibleForTesting + internal val googleCloudProjectId: Long, + @property:VisibleForTesting + internal val integrityManager: StandardIntegrityManager = createStandard(context), + @property:VisibleForTesting + internal val remoteAccessConsumerKey: String, + @property:VisibleForTesting + internal val restClient: RestClient, +) { + + + /** The Google Play Integrity API Token Provider */ + @VisibleForTesting + internal var integrityTokenProvider: StandardIntegrityTokenProvider? = null + + init { + prepareIntegrityTokenProvider() + } + + /** + * (Re-)prepares the Google Play Integrity Token Provider. Calling this + * prior to requesting the Integrity Token via + * [createAppAttestation] reduces the latency of the request. + */ + @VisibleForTesting + internal fun prepareIntegrityTokenProvider() = integrityManager.prepareIntegrityToken( + PrepareIntegrityTokenRequest.builder() + .setCloudProjectNumber(googleCloudProjectId) + .build() + ).addOnSuccessListener( + ::onPrepareIntegrityTokenProviderSuccess + ).addOnFailureListener( + ::onPrepareIntegrityTokenProviderFailure + ) + + /** + * A success callback used by [prepareIntegrityTokenProvider]. + * @param tokenProvider The Google Play API Integrity Token Provider + */ + @VisibleForTesting + internal fun onPrepareIntegrityTokenProviderSuccess(tokenProvider: StandardIntegrityTokenProvider) { + integrityTokenProvider = tokenProvider + } + + /** + * A failure callback for [prepareIntegrityTokenProvider]. + * @param exception The exception provided by Google Play Integrity API + */ + @VisibleForTesting + internal fun onPrepareIntegrityTokenProviderFailure(exception: Exception) { + w(javaClass.name, "Failed to prepare Google Play Integrity Token Provider: '${exception.message}'. App Attestation will be disabled.") + } + + /** + * Creates a Salesforce App Attestation External Client App (ECA) Plugin + * "attestation". First a Salesforce Mobile App Attestation "Challenge" is + * requested for the device id. Then, a Google Play Integrity API Token is + * fetched using the "Challenge" as the Request Hash. The resulting token is + * encoded into a value usable as the "attestation" parameter in the + * Salesforce OAuth authorization request. + * + * This method is not intended for public use outside of Salesforce Mobile + * SDK. + * + * TODO: Make this Kotlin-internal once it is no longer referenced by Java. ECJ20260420 + * + * @param appAttestationChallenge The Salesforce Mobile App Attestation + * External Client App (ECA) Plug-In "Challenge" to use + * @param integrityTokenProvider The Google Play App Integrity API Integrity + * Token Provider. This parameter is intended for testing purposes only + * @param canRetryOnInvalidTokenProvider When true (the default), a single + * inline retry with a freshly prepared Integrity Token Provider is allowed + * if the request fails with [INTEGRITY_TOKEN_PROVIDER_INVALID]. The + * recursive retry call sets this false to guarantee at most one retry + * and prevent unbounded recursion on the caller thread + * @return The "attestation" value usable in Salesforce OAuth authorization + * and token refresh requests or null if the value cannot be created + */ + suspend fun createAppAttestation( + appAttestationChallenge: String, + integrityTokenProvider: StandardIntegrityTokenProvider? = this.integrityTokenProvider, + canRetryOnInvalidTokenProvider: Boolean = true, + ): String? { + // Guard to ensure the Google Play Integrity API Integrity Provider was asynchronously resolved or do so synchronously now. + val integrityTokenProviderResolved = integrityTokenProvider ?: prepareIntegrityTokenProvider().await() + + // Fetch the Challenge from Salesforce Mobile App Attestation. + val salesforceAppAttestationChallengeHashByteArray = MessageDigest.getInstance("SHA-256") + .digest(appAttestationChallenge.toByteArray(UTF_8)) + val salesforceAppAttestationChallengeHashHexString = salesforceAppAttestationChallengeHashByteArray.joinToString("") { "%02x".format(it) } + + // Request the Google Play Integrity Token. + val integrityTokenResponse = integrityTokenProviderResolved.request( + StandardIntegrityTokenRequest.builder() + .setRequestHash(salesforceAppAttestationChallengeHashHexString) + .build() + ) + + /* + * Wait for the Google Play Integrity API response and return the + * Base64-encoded Salesforce OAuth authorization attestation parameter + * JSON. This may block the calling thread if the Google Play Integrity + * API introduces latency, though latency is expected to minimal as the + * API will have been prepared earlier in most scenarios. + */ + return runCatching { + integrityTokenResponse.await() + + // When the Google Play Integrity API response is received, return the Base64-encoded Salesforce OAuth authorization attestation parameter JSON. + OAuthAuthorizationAttestation( + attestationId = deviceId, + attestationData = Base64.getEncoder().encodeToString( + integrityTokenResponse.getResult().token().encodeToByteArray() + ) + ).toBase64String() + }.getOrElse { e -> + // If the Google Play Integrity API failed due to the Integrity Token Provider being expired, re-prepare it once for an inline retry. + // 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. + if (canRetryOnInvalidTokenProvider && (e as? IntegrityServiceException)?.errorCode == INTEGRITY_TOKEN_PROVIDER_INVALID) { + createAppAttestation( + appAttestationChallenge = appAttestationChallenge, + integrityTokenProvider = null, + canRetryOnInvalidTokenProvider = false, + ) + } else { + null + } + } + } + + /** + * A blocking Java-callable wrapper for [createAppAttestation] + * + * This method is not intended for public use outside of Salesforce Mobile + * SDK. + * + * TODO: Remove method when no longer referenced by Java. ECJ20260420 + * @param appAttestationChallenge The Salesforce Mobile App Attestation + * External Client App (ECA) Plug-In "Challenge" to use + */ + fun createAppAttestationBlocking(appAttestationChallenge: String) = runBlocking { + createAppAttestation(appAttestationChallenge) + } + + /** + * Fetches a new "Challenge" from the Salesforce App Attestation External + * Client App (ECA) Plug-In. + * + * This method is not intended for public use outside of Salesforce Mobile + * SDK. + * + * TODO: Make this Kotlin-internal once it is no longer referenced by Java. ECJ20260420 + * + * @return The Salesforce App Attestation ECA Plug-In's "Challenge" + */ + fun fetchMobileAppAttestationChallenge(): String { + // Create the Salesforce App Attestation Challenge API client and fetch a new challenge. + val appAttestationChallengeApiClient = AppAttestationChallengeApiClient( + apiHostName = apiHostName, + restClient = restClient + ) + return appAttestationChallengeApiClient.fetchChallenge( + attestationId = deviceId, + remoteConsumerKey = remoteAccessConsumerKey + ) + } +} + +/** + * 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 attestationData The token provided by the Google Play Integrity API + */ +@Serializable +internal data class OAuthAuthorizationAttestation( + val attestationId: String, + val attestationData: String, +) { + + /** + * Returns a Base64-encoded JSON representation of this object. + * + * Note: Standard Base64 alphabet with padding is used by design. The + * Salesforce App Attestation server-side contract requires the + * standard (not URL-safe) Base64 encoding with padding, and the value + * is consumed as-is without URI percent-encoding at the token endpoint + * (see OAuth2.makeTokenEndpointRequest). This has been verified + * end-to-end; do not switch to Base64.getUrlEncoder() or strip padding. + */ + fun toBase64String(): String? = Base64.getEncoder().encodeToString(Json.encodeToString(serializer(), this).encodeToByteArray()) +} + diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt index 457a2c4db3..4f99a177e1 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt @@ -31,6 +31,7 @@ import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP import android.content.pm.PackageManager.FEATURE_FACE import android.content.pm.PackageManager.FEATURE_IRIS +import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.Q import android.os.Build.VERSION_CODES.R @@ -54,6 +55,7 @@ import androidx.fragment.app.FragmentActivity import com.salesforce.androidsdk.R.string.sf__biometric_opt_in_title import com.salesforce.androidsdk.app.SalesforceSDKManager 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 @@ -83,6 +85,7 @@ import com.salesforce.androidsdk.auth.interfaces.NativeLoginResult.UnknownError import com.salesforce.androidsdk.auth.interfaces.OtpRequestResult import com.salesforce.androidsdk.auth.interfaces.OtpVerificationMethod import com.salesforce.androidsdk.rest.ClientManager +import com.salesforce.androidsdk.rest.RestClient import com.salesforce.androidsdk.rest.RestClient.AsyncRequestCallback import com.salesforce.androidsdk.rest.RestRequest import com.salesforce.androidsdk.rest.RestRequest.RestEndpoint.LOGIN @@ -119,6 +122,9 @@ import kotlin.coroutines.suspendCoroutine * Google Cloud Console. Defaults to null to disable reCAPTCHA use * @param isReCaptchaEnterprise Specifies if reCAPTCHA uses the enterprise * license. Defaults to false to disable reCAPTCHA use + * @param restClient The REST client to use for making network requests. This + * parameter is intended for testing purposes only. Defaults to the + * unauthenticated REST client */ internal class NativeLoginManager( private val clientId: String, @@ -126,7 +132,8 @@ internal class NativeLoginManager( private val loginUrl: String, private val reCaptchaSiteKeyId: String? = null, private val googleCloudProjectId: String? = null, - private val isReCaptchaEnterprise: Boolean = false + private val isReCaptchaEnterprise: Boolean = false, + private val restClient: RestClient = SalesforceSDKManager.getInstance().clientManager.peekUnauthenticatedRestClient() ) : NativeLoginManager { private val accountManager = SalesforceSDKManager.getInstance().userAccountManager @@ -162,7 +169,12 @@ internal class NativeLoginManager( CONTENT_TYPE_HEADER_NAME to CONTENT_TYPE_VALUE_HTTP_POST, AUTHORIZATION to "$AUTH_AUTHORIZATION_VALUE_BASIC $encodedCreds", ) + val attestationValue = SalesforceSDKManager.getInstance().appAttestationClient?.run { + val challenge = fetchMobileAppAttestationChallenge() + createAppAttestation(challenge) ?: return@run null + } val authRequestBody = createRequestBody( + ATTESTATION to attestationValue, RESPONSE_TYPE to CODE_CREDENTIALS, CLIENT_ID to clientId, REDIRECT_URI to redirectUri, @@ -255,8 +267,7 @@ internal class NativeLoginManager( private suspend fun suspendedRestCall(request: RestRequest): RestResponse? { return suspendCoroutine { continuation -> - SalesforceSDKManager.getInstance().clientManager - .peekUnauthenticatedRestClient().sendAsync(request, object : AsyncRequestCallback { + restClient.sendAsync(request, object : AsyncRequestCallback { override fun onSuccess(request: RestRequest?, response: RestResponse?) { continuation.resume(response) @@ -272,8 +283,9 @@ internal class NativeLoginManager( } } - private fun createRequestBody(vararg kvPairs: Pair): RequestBody { - val requestBodyString = kvPairs.joinToString("&") { (key, value) -> "$key=$value" } + @VisibleForTesting + internal fun createRequestBody(vararg kvPairs: Pair): RequestBody { + val requestBodyString = kvPairs.filter { it.second != null }.joinToString("&") { (key, value) -> "$key=${Uri.encode(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 fe23059694..6b3b94b2ca 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java @@ -31,6 +31,7 @@ import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; import com.salesforce.androidsdk.app.SalesforceSDKManager; import com.salesforce.androidsdk.rest.RestResponse; @@ -59,7 +60,7 @@ /** * Helper methods for common OAuth2 requests. - * + *

* The typical OAuth2 flow is: * *

    @@ -103,6 +104,17 @@ 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: + * Salesforce App Attestation External Client App Attestation + *

    + * This method is not intended for public use outside of Salesforce Mobile + * SDK. + *

    + * TODO: Make this internal when no longer referenced by Java. ECJ20260421 + */ + public 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"; @@ -163,7 +175,6 @@ public class OAuth2 { private static final String CSRF_TOKEN = "csrf_token"; private static final String EMPTY_STRING = ""; private static final String FORWARD_SLASH = "/"; - private static final String SINGLE_SPACE = " "; private static final String TAG = "OAuth2"; private static final String ID_URL = "id"; private static final String ASSERTED_USER = "asserted_user"; @@ -238,7 +249,7 @@ public String toString() { /** * Builds the URL to the authorization web page for this login server. * You need not provide the 'refresh_token' scope, as it is provided automatically. - * + *

    * This overload defaults `loginHint` to null and does not enable Salesforce Welcome Login hint. * * @param useWebServerAuthentication True to use web server flow, False to use user agent flow @@ -250,7 +261,11 @@ public String toString() { * the default OAuth scope is provided. * @param displayType OAuth display type. If null, the default of 'touch' is used. * @param codeChallenge Code challenge to use when using web server flow - * @param addlParams Any additional parameters that may be added to the request. + * @param addlParams Any additional parameters that may be + * added to the request. When using + * Salesforce Mobile App Attestation, the + * "attestation" parameter should be added + * to this map. * @return A URL to start the OAuth flow in a web browser/view. * @see RemoteAccess OAuth Scopes */ @@ -292,7 +307,11 @@ public static URI getAuthorizationUrl( * @param loginHint When applicable, the Salesforce Welcome Login hint * @param displayType OAuth display type. If null, the default of 'touch' is used. * @param codeChallenge Code challenge to use when using web server flow - * @param addlParams Any additional parameters that may be added to the request. + * @param addlParams Any additional parameters that may be + * added to the request. When using + * Salesforce Mobile App Attestation, the + * "attestation" parameter should be added + * to this map. * @return A URL to start the OAuth flow in a web browser/view. * @see RemoteAccess OAuth Scopes */ @@ -306,8 +325,9 @@ public static URI getAuthorizationUrl( String loginHint, String displayType, String codeChallenge, - Map addlParams) { + Map addlParams) { final StringBuilder sb = new StringBuilder(loginServer.toString()); + final String responseType = useWebServerAuthentication ? CODE : useHybridAuthentication ? HYBRID_TOKEN : TOKEN; @@ -326,7 +346,7 @@ public static URI getAuthorizationUrl( if (useWebServerAuthentication) { sb.append(AND).append(CODE_CHALLENGE).append(EQUAL).append(Uri.encode(codeChallenge)); } - if (addlParams != null && addlParams.size() > 0) { + if (addlParams != null && !addlParams.isEmpty()) { for (final Map.Entry entry : addlParams.entrySet()) { final String value = entry.getValue() == null ? EMPTY_STRING : entry.getValue(); sb.append(AND).append(entry.getKey()).append(EQUAL).append(Uri.encode(value)); @@ -411,6 +431,17 @@ public static TokenEndpointResponse exchangeCode(HttpAccess httpAccessor, URI lo String clientId, String code, String codeVerifier, String callbackUrl) throws OAuthFailedException, IOException { + return exchangeCode(httpAccessor, loginServer, clientId, code, codeVerifier, callbackUrl, SalesforceSDKManager.getInstance()); + } + + /** + * An internal, testable Salesforce Mobile SDK overload of + * {@link #exchangeCode(HttpAccess, URI, String, String, String, String)}. + */ + public static TokenEndpointResponse exchangeCode(HttpAccess httpAccessor, URI loginServer, + String clientId, String code, String codeVerifier, + String callbackUrl, SalesforceSDKManager salesforceSdkManager) + throws OAuthFailedException, IOException { final FormBody.Builder builder = new FormBody.Builder(); final boolean useHybridAuthentication = SalesforceSDKManager.getInstance().shouldUseHybridAuthentication(); final String grantType = useHybridAuthentication ? HYBRID_AUTH_CODE : AUTHORIZATION_CODE; @@ -420,7 +451,7 @@ public static TokenEndpointResponse exchangeCode(HttpAccess httpAccessor, URI lo builder.add(CODE, code); builder.add(CODE_VERIFIER, codeVerifier); builder.add(REDIRECT_URI, callbackUrl); - return makeTokenEndpointRequest(httpAccessor, loginServer, builder); + return makeTokenEndpointRequest(httpAccessor, loginServer, builder, salesforceSdkManager); } /** @@ -455,7 +486,7 @@ public static TokenEndpointResponse refreshAuthToken(HttpAccess httpAccessor, UR } } } - return makeTokenEndpointRequest(httpAccessor, loginServer, builder); + return makeTokenEndpointRequest(httpAccessor, loginServer, builder, SalesforceSDKManager.getInstance()); } /** @@ -501,7 +532,7 @@ public static TokenEndpointResponse swapJWTForTokens(HttpAccess httpAccessor, UR String jwt) throws IOException, OAuthFailedException { final FormBody.Builder formBodyBuilder = new FormBody.Builder().add(GRANT_TYPE, JWT_BEARER) .add(ASSERTION, jwt); - return makeTokenEndpointRequest(httpAccessor, loginServerUrl, formBodyBuilder); + return makeTokenEndpointRequest(httpAccessor, loginServerUrl, formBodyBuilder, SalesforceSDKManager.getInstance()); } /** @@ -515,9 +546,9 @@ public static TokenEndpointResponse swapJWTForTokens(HttpAccess httpAccessor, UR * * @throws IOException See {@link IOException}. */ - public static final IdServiceResponse callIdentityService(HttpAccess httpAccessor, - String identityServiceIdUrl, - String authToken) + public static IdServiceResponse callIdentityService(HttpAccess httpAccessor, + String identityServiceIdUrl, + String authToken) throws IOException { final Request.Builder builder = new Request.Builder().url(identityServiceIdUrl).get(); addAuthorizationHeader(builder, authToken); @@ -532,17 +563,34 @@ public static final IdServiceResponse callIdentityService(HttpAccess httpAccesso * @param builder Builder instance. * @param authToken Access token. */ - public static final Request.Builder addAuthorizationHeader(Request.Builder builder, String authToken) { + public static Request.Builder addAuthorizationHeader(Request.Builder builder, String authToken) { return builder.header(AUTHORIZATION, BEARER + authToken); } - private static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAccessor, - URI loginServer, - FormBody.Builder formBodyBuilder) + @VisibleForTesting + @WorkerThread + public static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAccessor, + URI loginServer, + FormBody.Builder formBodyBuilder, + SalesforceSDKManager salesforceSdkManager) throws OAuthFailedException, IOException { + final StringBuilder sb = new StringBuilder(loginServer.toString()); sb.append(OAUTH_TOKEN_PATH); - sb.append(QUESTION).append(DEVICE_ID).append(EQUAL).append(SalesforceSDKManager.getInstance().getDeviceId()); + sb.append(QUESTION).append(DEVICE_ID).append(EQUAL).append(salesforceSdkManager.getDeviceId()); + + final AppAttestationClient appAttestationClient = salesforceSdkManager.getAppAttestationClient(); + final String challenge = appAttestationClient != null ? appAttestationClient.fetchMobileAppAttestationChallenge() : null; + final String attestationValue = challenge != null ? appAttestationClient.createAppAttestationBlocking(challenge) : null; + if (attestationValue != null) { + // Note: The attestation value is appended to the token endpoint + // query string without Uri.encode by design. The value produced + // by OAuthAuthorizationAttestation.toBase64String() is accepted + // as-is by the Salesforce token endpoint's server-side contract. + // This has been verified end-to-end; do not wrap in Uri.encode. + sb.append(AND).append(ATTESTATION).append(EQUAL).append(attestationValue); + } + 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/auth/idp/IDPAuthCodeHelper.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt index 42429daf39..104cccb4f1 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt @@ -30,9 +30,12 @@ import android.net.Uri import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient +import androidx.annotation.VisibleForTesting import com.salesforce.androidsdk.R import com.salesforce.androidsdk.accounts.UserAccount import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.auth.AppAttestationClient +import com.salesforce.androidsdk.auth.OAuth2.ATTESTATION import com.salesforce.androidsdk.auth.OAuth2.FRONTDOOR_URL_KEY import com.salesforce.androidsdk.auth.OAuth2.getAuthorizationUrl import com.salesforce.androidsdk.rest.ClientManager @@ -50,12 +53,13 @@ import java.net.URI /** * Helper class used in IDP app to get auth code from server */ -internal class IDPAuthCodeHelper private constructor( +internal class IDPAuthCodeHelper @VisibleForTesting internal constructor( val webView: WebView, val userAccount: UserAccount, val spConfig: SPConfig, val codeChallenge: String, - val onResult:(result:Result) -> Unit + val onResult: (result: Result) -> Unit, + val appAttestationClient: AppAttestationClient? = SalesforceSDKManager.getInstance().appAttestationClient, ) { data class Result( val success: Boolean, @@ -103,10 +107,19 @@ internal class IDPAuthCodeHelper private constructor( * Compute relative path of authorization url for SP * @return authorization relative path */ - fun getAuthorizationPathForSP(): String? { + @VisibleForTesting + internal suspend fun getAuthorizationPathForSP(): String? { SalesforceSDKLogger.d(TAG, "Getting authorization url") val context = SalesforceSDKManager.getInstance().appContext val useHybridAuthentication = SalesforceSDKManager.getInstance().useHybridAuthentication + + // Add Salesforce Mobile App Attestation parameter to authorization URL if applicable. + val additionalParams = appAttestationClient?.run { + val challenge = fetchMobileAppAttestationChallenge() + val attestation = createAppAttestation(challenge) ?: return@run null + mapOf(ATTESTATION to attestation) + } + val authorizationUri = getAuthorizationUrl( true, // use web server flow useHybridAuthentication, @@ -116,12 +129,12 @@ internal class IDPAuthCodeHelper private constructor( spConfig.oauthScopes, context.getString(R.string.oauth_display_type), codeChallenge, - null + additionalParams ) return authorizationUri?.let { it.path + (it.query?.let { query -> "?$query" } ?: "") - } ?: null + } } fun getFrontdoorUrl(restClient:RestClient, redirectUri: String): String? { 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..80f82cafdf --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/AppAttestationChallengeApiClient.kt @@ -0,0 +1,80 @@ +/* + * 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 android.net.Uri +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 App Attestation Challenge API host + * @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(AppAttestationChallengeApiException::class) + fun fetchChallenge( + attestationId: String, + remoteConsumerKey: String + ): String { + + // Submit the request. + val restRequest = RestRequest( + GET, + "https://$apiHostName/mobile/attest/challenge?attestationId=${Uri.encode(attestationId)}&consumerKey=${Uri.encode(remoteConsumerKey)}" + ) + val restResponse = restClient.sendSync(restRequest) + val responseBodyString = restResponse.asString() + return if (restResponse.isSuccess && responseBodyString != null) { + responseBodyString + } else { + throw AppAttestationChallengeApiException( + 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..7f805bd427 --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/AppAttestationChallengeApiException.kt @@ -0,0 +1,39 @@ +/* + * 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 + * @param source The original Salesforce Mobile App Attestation Challenge API + * error response body + */ +class AppAttestationChallengeApiException( + val source: String? +) : Exception() diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index 6bae43cc3e..4414e4bb33 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -57,6 +57,7 @@ import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.DARK import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.LIGHT import com.salesforce.androidsdk.auth.HttpAccess import com.salesforce.androidsdk.auth.OAuth2 +import com.salesforce.androidsdk.auth.OAuth2.ATTESTATION import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse import com.salesforce.androidsdk.auth.OAuth2.exchangeCode import com.salesforce.androidsdk.auth.OAuth2.getFrontdoorUrl @@ -72,7 +73,6 @@ import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getSHA256Hash import com.salesforce.androidsdk.ui.LoginActivity.Companion.ABOUT_BLANK import com.salesforce.androidsdk.ui.LoginActivity.Companion.isSalesforceWelcomeDiscoveryUrlPath import com.salesforce.androidsdk.util.SalesforceSDKLogger.e -import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job @@ -80,6 +80,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.net.URI +import kotlin.coroutines.CoroutineContext open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { @@ -446,7 +447,7 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { * @param server The login server URL * @param migrationOAuthConfig The OAuth config to use for migration */ - internal fun generateMigrationAuthorizationPath( + internal suspend fun generateMigrationAuthorizationPath ( server: String, migrationOAuthConfig: OAuthConfig, sdkManager: SalesforceSDKManager = SalesforceSDKManager.getInstance(), @@ -454,6 +455,15 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { val codeVerifier = getRandom128ByteKey().also { codeVerifier = it } val codeChallenge = getSHA256Hash(codeVerifier) + // Populate the additional parameter map with app attestation, if applicable. + val additionalParameters = mutableMapOf() + sdkManager.appAttestationClient?.run { + val challenge = fetchMobileAppAttestationChallenge() + val attestation = createAppAttestation(challenge) + if (attestation == null) return@run + additionalParameters[ATTESTATION] = attestation + } + val authorizationUrl = OAuth2.getAuthorizationUrl( /* useWebServerAuthentication = */ true, sdkManager.useHybridAuthentication, @@ -464,12 +474,12 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { /* loginHint = */ null, authorizationDisplayType, codeChallenge, - /* addlParams = */ emptyMap(), + /* addlParams = */ additionalParameters, ) return with(authorizationUrl) { "$path?$query" } } - + /** * Generates an OAuth authorization URL for the provided server. * @param server The login server URL @@ -488,10 +498,11 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { // Perform heavy work (config fetch, URL generation) on the IO dispatcher. val (browserTabUrl, webViewUrl) = withContext(coroutineContext) { + val debugOverrideAppConfig = sdkManager.debugOverrideAppConfig with(sdkManager) { oAuthConfig = when { // Used by LoginOptions - isDebugBuild && debugOverrideAppConfig != null -> debugOverrideAppConfig!! + isDebugBuild && debugOverrideAppConfig != null -> debugOverrideAppConfig // Check if app has a config and fallback to bootconfig file. else -> appConfigForLoginHost(server) ?: OAuthConfig(bootConfig) } @@ -499,13 +510,20 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { val jwtFlow = !jwt.isNullOrBlank() && !authCodeForJwtFlow.isNullOrBlank() val additionalParams = when { - jwtFlow -> null + jwtFlow -> mutableMapOf() else -> additionalParameters } val codeVerifier = getRandom128ByteKey().also { codeVerifier = it } val codeChallenge = getSHA256Hash(codeVerifier) + // Populate the additional parameter map with app attestation, if applicable. + sdkManager.appAttestationClient?.run { + val challenge = fetchMobileAppAttestationChallenge() + val attestation = createAppAttestation(challenge) ?: return@run + additionalParams[ATTESTATION] = attestation + } + val webServerAuthorizationUrl = OAuth2.getAuthorizationUrl( /* useWebServerAuthentication = */ true, sdkManager.useHybridAuthentication, diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt index 0129114cf7..81b48f7a1e 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt @@ -3,6 +3,7 @@ package com.salesforce.androidsdk.app import android.app.Activity import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.salesforce.androidsdk.auth.HttpAccess import com.salesforce.androidsdk.config.LoginServerManager.LoginServer import com.salesforce.androidsdk.config.LoginServerManager.PRODUCTION_LOGIN_URL @@ -22,6 +23,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -288,4 +290,30 @@ class SalesforceSDKManagerTests { assertNotNull(devActions["Show dev info"]) assertNotNull(devActions["Login Options"]) } + + @Test + fun salesforceSdkManager_updateAppAttestationClient_setsAndUnsetsAppAttestationClientForGoogleCloudProjectId() { + + val salesforceSdkManager = SalesforceSDKManager( + context = getInstrumentation().targetContext, + mainActivity = LoginActivity::class.java, /* Any Activity Class */ + loginActivity = LoginActivity::class.java, + ) + + salesforceSdkManager.updateAppAttestationClient( + apiHostName = "login.example.com", + googleCloudProjectId = 123456 + ) + + val appAttestationClient = salesforceSdkManager.appAttestationClient + assertEquals(123456L, appAttestationClient?.googleCloudProjectId) + assertEquals("login.example.com", appAttestationClient?.apiHostName) + assertNotNull(appAttestationClient?.deviceId) + assertEquals("__CONSUMER_KEY__", appAttestationClient?.remoteAccessConsumerKey) + assertNotNull(appAttestationClient?.restClient) + + salesforceSdkManager.updateAppAttestationClient("https://login.example.com" /* null default */) + + assertNull(salesforceSdkManager.appAttestationClient) + } } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AppAttestationChallengeApiClientTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AppAttestationChallengeApiClientTest.kt new file mode 100644 index 0000000000..fd6f0e578b --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AppAttestationChallengeApiClientTest.kt @@ -0,0 +1,123 @@ +/* + * 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.auth + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.salesforce.androidsdk.rest.AppAttestationChallengeApiClient +import com.salesforce.androidsdk.rest.AppAttestationChallengeApiException +import com.salesforce.androidsdk.rest.RestClient +import com.salesforce.androidsdk.rest.RestResponse +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AppAttestationChallengeApiClientTest { + + @Test + fun appAttestationChallengeApiClient_fetchChallenge_returnsChallengeOnSuccess() { + + val client = createClient(body = TEST_CHALLENGE_VALUE, success = true) + + val result = client.fetchChallenge( + attestationId = TEST_ATTESTATION_ID, + remoteConsumerKey = TEST_REMOTE_CONSUMER_KEY, + ) + + assertEquals(TEST_CHALLENGE_VALUE, result) + } + + @Test + fun appAttestationChallengeApiClient_fetchChallenge_throwsWhenRestResponseIsNotSuccess() { + + val client = createClient(body = TEST_CHALLENGE_VALUE, success = false) + + assertThrows(AppAttestationChallengeApiException::class.java) { + client.fetchChallenge( + attestationId = TEST_ATTESTATION_ID, + remoteConsumerKey = TEST_REMOTE_CONSUMER_KEY, + ) + } + } + + @Test + fun appAttestationChallengeApiClient_fetchChallenge_throwsWhenRestResponseBodyStringIsNull() { + + val client = createClient(body = null, success = true) + + assertThrows(AppAttestationChallengeApiException::class.java) { + client.fetchChallenge( + attestationId = TEST_ATTESTATION_ID, + remoteConsumerKey = TEST_REMOTE_CONSUMER_KEY, + ) + } + } + + @Test + fun appAttestationChallengeApiClient_fetchChallenge_throwsWhenRestResponseIsNotSuccessAndBodyStringIsNull() { + + val client = createClient(body = null, success = false) + + assertThrows(AppAttestationChallengeApiException::class.java) { + client.fetchChallenge( + attestationId = TEST_ATTESTATION_ID, + remoteConsumerKey = TEST_REMOTE_CONSUMER_KEY, + ) + } + } + + // region Helpers + + private fun createClient( + body: String?, + success: Boolean, + ): AppAttestationChallengeApiClient { + val restResponse = mockk(relaxed = true).apply { + every { asString() } returns body + every { isSuccess } returns success + } + val restClient = mockk(relaxed = true).apply { + every { sendSync(any()) } returns restResponse + } + return AppAttestationChallengeApiClient( + apiHostName = TEST_API_HOST_NAME, + restClient = restClient, + ) + } + + // endregion Helpers + + private companion object { + const val TEST_API_HOST_NAME = "https://www.example.com" + const val TEST_ATTESTATION_ID = "__ATTESTATION_ID__" + const val TEST_REMOTE_CONSUMER_KEY = "__REMOTE_CONSUMER_KEY__" + const val TEST_CHALLENGE_VALUE = "__TEST_CHALLENGE_VALUE__" + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AppAttestationClientTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AppAttestationClientTest.kt new file mode 100644 index 0000000000..75a5db88ef --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AppAttestationClientTest.kt @@ -0,0 +1,480 @@ +/* + * 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.auth + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.tasks.Task +import com.google.android.play.core.integrity.IntegrityServiceException +import com.google.android.play.core.integrity.StandardIntegrityManager +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityToken +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider +import com.google.android.play.core.integrity.model.StandardIntegrityErrorCode.INTEGRITY_TOKEN_PROVIDER_INVALID +import com.google.android.play.core.integrity.model.StandardIntegrityErrorCode.INTERNAL_ERROR +import com.salesforce.androidsdk.rest.AppAttestationChallengeApiException +import com.salesforce.androidsdk.rest.RestClient +import com.salesforce.androidsdk.rest.RestRequest +import com.salesforce.androidsdk.rest.RestResponse +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@Suppress("OPT_IN_USAGE") +@RunWith(AndroidJUnit4::class) +class AppAttestationClientTest { + + @Test + fun appAttestationClient_prepareIntegrityTokenProvider_returnsSuccessfully() { + + val integrityTokenProviderTask = mockk>(relaxed = true).also { task -> + every { task.addOnSuccessListener(any()) } returns task + every { task.addOnFailureListener(any()) } returns task + } + val integrityManager = mockk(relaxed = true).also { manager -> + every { manager.prepareIntegrityToken(any()) } returns integrityTokenProviderTask + } + + createAppAttestationClientForTest(integrityManager = integrityManager) + + verify(exactly = 1) { + integrityManager.prepareIntegrityToken(match { + it.toString().contains("cloudProjectNumber=$TEST_GOOGLE_CLOUD_PROJECT_ID") + }) + } + verify(exactly = 1) { integrityTokenProviderTask.addOnSuccessListener(any()) } + verify(exactly = 1) { integrityTokenProviderTask.addOnFailureListener(any()) } + } + + @Test + fun appAttestationClient_onPrepareIntegrityTokenProviderSuccess_assignsIntegrityTokenProvider() { + + val integrityTokenProvider = mockk(relaxed = true) + val appAttestationClient = createAppAttestationClientForTest() + + appAttestationClient.onPrepareIntegrityTokenProviderSuccess(tokenProvider = integrityTokenProvider) + + assertEquals(integrityTokenProvider, appAttestationClient.integrityTokenProvider) + } + + @Test + fun appAttestationClient_onPrepareIntegrityTokenProviderFailure_justRuns() { + + val appAttestationClient = createAppAttestationClientForTest() + + appAttestationClient.onPrepareIntegrityTokenProviderFailure(exception = RuntimeException()) + + /* Intentionally Blank */ + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun appAttestationClient_createSalesforceOAuthAuthorizationAppAttestation_returnsSuccessfully() = runTest { + + val integrityTokenProvider = createSuccessfulIntegrityTokenProvider() + val appAttestationClient = createAppAttestationClientForTest() + + val result = appAttestationClient.createAppAttestation( + appAttestationChallenge = TEST_CHALLENGE_VALUE, + integrityTokenProvider = integrityTokenProvider, + ) + + advanceUntilIdle() + + assertEquals(EXPECTED_ATTESTATION_RESULT, result) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun appAttestationClient_createSalesforceOAuthAuthorizationAppAttestationThrowingForInvalidIntegrityTokenProvider_returnsSuccessfully() = runTest { + + val throwingIntegrityTokenProvider = createThrowingIntegrityTokenProvider( + throwable = createIntegrityServiceException(errorCode = INTEGRITY_TOKEN_PROVIDER_INVALID), + ) + val integrityManager = createMockIntegrityManagerResolvingTo( + provider = createSuccessfulIntegrityTokenProvider(), + ) + val appAttestationClient = createAppAttestationClientForTest(integrityManager = integrityManager) + + val result = appAttestationClient.createAppAttestation( + appAttestationChallenge = TEST_CHALLENGE_VALUE, + integrityTokenProvider = throwingIntegrityTokenProvider, + ) + + advanceUntilIdle() + + assertEquals(EXPECTED_ATTESTATION_RESULT, result) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun appAttestationClient_createAppAttestation_whenBothProvidersThrowInvalidTokenProvider_retriesAtMostOnceAndReturnsNull() = runTest { + + val throwingIntegrityTokenProvider = createThrowingIntegrityTokenProvider( + throwable = createIntegrityServiceException(errorCode = INTEGRITY_TOKEN_PROVIDER_INVALID), + ) + val integrityManager = createMockIntegrityManagerResolvingTo( + provider = createThrowingIntegrityTokenProvider( + throwable = createIntegrityServiceException(errorCode = INTEGRITY_TOKEN_PROVIDER_INVALID), + ), + ) + val appAttestationClient = createAppAttestationClientForTest(integrityManager = integrityManager) + + val result = appAttestationClient.createAppAttestation( + appAttestationChallenge = TEST_CHALLENGE_VALUE, + integrityTokenProvider = throwingIntegrityTokenProvider, + ) + + advanceUntilIdle() + + assertNull(result) + // integrityManager.prepareIntegrityToken is called exactly twice: once from the constructor's init {} block, + // and exactly once more for the single inline retry. A count > 2 would indicate unbounded recursion. + verify(exactly = 2) { integrityManager.prepareIntegrityToken(any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun appAttestationClient_createAppAttestation_whenCanRetryIsFalseAndProviderThrowsInvalid_shortCircuitsWithoutRetry() = runTest { + + val throwingIntegrityTokenProvider = createThrowingIntegrityTokenProvider( + throwable = createIntegrityServiceException(errorCode = INTEGRITY_TOKEN_PROVIDER_INVALID), + ) + val integrityManager = createMockIntegrityManagerResolvingTo( + provider = createSuccessfulIntegrityTokenProvider(), + ) + val appAttestationClient = createAppAttestationClientForTest(integrityManager = integrityManager) + + val result = appAttestationClient.createAppAttestation( + appAttestationChallenge = TEST_CHALLENGE_VALUE, + integrityTokenProvider = throwingIntegrityTokenProvider, + canRetryOnInvalidTokenProvider = false, + ) + + advanceUntilIdle() + + assertNull(result) + // Only the constructor's init {} block may call prepareIntegrityToken; no retry is allowed when canRetryOnInvalidTokenProvider = false. + verify(exactly = 1) { integrityManager.prepareIntegrityToken(any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun appAttestationClient_createSalesforceOAuthAuthorizationAppAttestationThrowingForUnknownIntegrityServiceException_returnsSuccessfully() = runTest { + + val throwingIntegrityTokenProvider = createThrowingIntegrityTokenProvider( + throwable = createIntegrityServiceException(errorCode = INTERNAL_ERROR), + ) + val appAttestationClient = createAppAttestationClientForTest() + + val result = appAttestationClient.createAppAttestation( + appAttestationChallenge = TEST_CHALLENGE_VALUE, + integrityTokenProvider = throwingIntegrityTokenProvider, + ) + + advanceUntilIdle() + + assertNull(result) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun appAttestationClient_createSalesforceOAuthAuthorizationAppAttestationThrowingUnknownException_returnsNull() = runTest { + + val throwingIntegrityTokenProvider = createThrowingIntegrityTokenProvider( + throwable = RuntimeException("Unknown Exception"), + ) + val appAttestationClient = createAppAttestationClientForTest() + + val result = appAttestationClient.createAppAttestation( + appAttestationChallenge = TEST_CHALLENGE_VALUE, + integrityTokenProvider = throwingIntegrityTokenProvider, + ) + + advanceUntilIdle() + + assertNull(result) + } + + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun appAttestationClient_createSalesforceOAuthAuthorizationAppAttestationWhenIntegrityTokenProviderIsNull_returnsSuccessfully() = runTest { + + val integrityManager = createMockIntegrityManagerResolvingTo( + provider = createSuccessfulIntegrityTokenProvider(), + ) + val appAttestationClient = createAppAttestationClientForTest(integrityManager = integrityManager) + + val result = appAttestationClient.createAppAttestation( + appAttestationChallenge = TEST_CHALLENGE_VALUE, + integrityTokenProvider = null, + ) + + advanceUntilIdle() + + assertEquals(EXPECTED_ATTESTATION_RESULT, result) + } + + @Test + fun appAttestationClient_createAppAttestationBlocking_returnsSuccessfully() { + + val integrityManager = createMockIntegrityManagerResolvingTo( + provider = createSuccessfulIntegrityTokenProvider(), + ) + val appAttestationClient = createAppAttestationClientForTest(integrityManager = integrityManager) + + val result = appAttestationClient.createAppAttestationBlocking( + appAttestationChallenge = TEST_CHALLENGE_VALUE, + ) + + assertEquals(EXPECTED_ATTESTATION_RESULT, result) + } + + @Test + fun appAttestationClient_fetchMobileAppAttestationChallenge_OnSuccess_ReturnsChallenge() { + + val requestSlot = slot() + val restClient = createRestClientReturning( + restResponse = createRestResponse(body = TEST_CHALLENGE_VALUE, success = true), + requestSlot = requestSlot, + ) + val appAttestationClient = createAppAttestationClientForTest(restClient = restClient) + + val result = appAttestationClient.fetchMobileAppAttestationChallenge() + + assertEquals(TEST_CHALLENGE_VALUE, result) + val requestedPath = requestSlot.captured.path + assertTrue( + "Request URL should target the attestation challenge endpoint at '$TEST_API_HOST_NAME' but was '$requestedPath'.", + requestedPath.startsWith("https://$TEST_API_HOST_NAME/mobile/attest/challenge"), + ) + assertTrue( + "Request URL should contain 'attestationId=$TEST_DEVICE_ID' but was '$requestedPath'.", + requestedPath.contains("attestationId=$TEST_DEVICE_ID"), + ) + assertTrue( + "Request URL should contain 'consumerKey=$TEST_REMOTE_ACCESS_CONSUMER_KEY' but was '$requestedPath'.", + requestedPath.contains("consumerKey=$TEST_REMOTE_ACCESS_CONSUMER_KEY"), + ) + verify(exactly = 1) { restClient.sendSync(any()) } + } + + @Test + fun appAttestationClient_fetchMobileAppAttestationChallenge_OnFailureResponse_ThrowsException() { + + val restClient = createRestClientReturning( + restResponse = createRestResponse(body = "__ERROR_BODY__", success = false), + ) + val appAttestationClient = createAppAttestationClientForTest(restClient = restClient) + + assertThrows(AppAttestationChallengeApiException::class.java) { + appAttestationClient.fetchMobileAppAttestationChallenge() + } + } + + @Test + fun appAttestationClient_fetchMobileAppAttestationChallenge_OnNullResponseBody_ThrowsException() { + + val restClient = createRestClientReturning( + restResponse = createRestResponse(body = null, success = true), + ) + val appAttestationClient = createAppAttestationClientForTest(restClient = restClient) + + assertThrows(AppAttestationChallengeApiException::class.java) { + appAttestationClient.fetchMobileAppAttestationChallenge() + } + } + + @Test + fun oAuthAuthorizationAttestation_encode_returnsSuccessfully() { + + val result = Json.decodeFromString( + OAuthAuthorizationAttestation.serializer(), + TEST_ATTESTATION_JSON, + ) + + assertEquals(TEST_ATTESTATION_ID, result.attestationId) + assertEquals(TEST_ATTESTATION_DATA, result.attestationData) + } + + @Test + fun oAuthAuthorizationAttestation_decodeWithUnknownField_returnsSuccessfully() { + + @Suppress("JSON_FORMAT_REDUNDANT") + val result = Json { ignoreUnknownKeys = true }.decodeFromString( + OAuthAuthorizationAttestation.serializer(), + TEST_ATTESTATION_JSON_WITH_UNKNOWN_FIELD, + ) + + assertEquals(TEST_ATTESTATION_ID, result.attestationId) + assertEquals(TEST_ATTESTATION_DATA, result.attestationData) + } + + @Test + fun oAuthAuthorizationAttestation_serializerDescriptor_hasCorrectElementCount() { + assertEquals(2, OAuthAuthorizationAttestation.serializer().descriptor.elementsCount) + } + + // region Helpers + + private fun createAppAttestationClientForTest( + restClient: RestClient = createSuccessfulRestClientForChallenge(), + integrityManager: StandardIntegrityManager = createMockIntegrityManagerWithInertProviderTask(), + ): AppAttestationClient = AppAttestationClient( + apiHostName = TEST_API_HOST_NAME, + context = mockk(relaxed = true), + deviceId = TEST_DEVICE_ID, + googleCloudProjectId = TEST_GOOGLE_CLOUD_PROJECT_ID, + integrityManager = integrityManager, + remoteAccessConsumerKey = TEST_REMOTE_ACCESS_CONSUMER_KEY, + restClient = restClient, + ) + + private fun createSuccessfulRestClientForChallenge(): RestClient = createRestClientReturning( + restResponse = createRestResponse(body = TEST_CHALLENGE_VALUE, success = true), + ) + + private fun createRestResponse( + body: String?, + success: Boolean, + ): RestResponse = mockk(relaxed = true).also { response -> + every { response.asString() } returns body + every { response.isSuccess } returns success + } + + private fun createRestClientReturning( + restResponse: RestResponse, + requestSlot: CapturingSlot = slot(), + ): RestClient = mockk(relaxed = true).also { client -> + every { client.sendSync(capture(requestSlot)) } returns restResponse + } + + private fun createMockIntegrityToken(): StandardIntegrityToken = + mockk(relaxed = true).also { token -> + every { token.token() } returns TEST_INTEGRITY_TOKEN + } + + private fun createSuccessfulIntegrityTokenTask(): Task { + val token = createMockIntegrityToken() + return mockk>(relaxed = true).also { task -> + every { task.addOnFailureListener(any()) } returns task + every { task.isComplete } returns true + every { task.isCanceled } returns false + every { task.exception } returns null + every { task.result } returns token + } + } + + private fun createThrowingIntegrityTokenTask( + throwable: Exception, + ): Task = + mockk>(relaxed = true).also { task -> + every { task.addOnFailureListener(any()) } returns task + every { task.isComplete } returns true + every { task.isCanceled } returns false + every { task.exception } returns throwable + } + + private fun createSuccessfulIntegrityTokenProvider(): StandardIntegrityTokenProvider { + val task = createSuccessfulIntegrityTokenTask() + return mockk(relaxed = true).also { provider -> + every { provider.request(any()) } returns task + } + } + + private fun createThrowingIntegrityTokenProvider( + throwable: Exception, + ): StandardIntegrityTokenProvider { + val task = createThrowingIntegrityTokenTask(throwable = throwable) + return mockk(relaxed = true).also { provider -> + every { provider.request(any()) } returns task + } + } + + private fun createIntegrityServiceException( + errorCode: Int, + ): IntegrityServiceException = mockk(relaxed = true).also { exception -> + every { exception.errorCode } returns errorCode + } + + private fun createMockIntegrityManagerWithInertProviderTask(): StandardIntegrityManager { + val providerTask = mockk>(relaxed = true).also { task -> + every { task.addOnSuccessListener(any()) } returns task + every { task.addOnFailureListener(any()) } returns task + } + return mockk(relaxed = true).also { manager -> + every { manager.prepareIntegrityToken(any()) } returns providerTask + } + } + + private fun createMockIntegrityManagerResolvingTo( + provider: StandardIntegrityTokenProvider, + ): StandardIntegrityManager { + val providerTask = mockk>(relaxed = true).also { task -> + every { task.addOnSuccessListener(any()) } returns task + every { task.addOnFailureListener(any()) } returns task + every { task.isComplete } returns true + every { task.isCanceled } returns false + every { task.exception } returns null + every { task.result } returns provider + } + return mockk(relaxed = true).also { manager -> + every { manager.prepareIntegrityToken(any()) } returns providerTask + } + } + + // endregion Helpers + + private companion object { + const val TEST_API_HOST_NAME = "login.example.com" + const val TEST_DEVICE_ID = "123456" + const val TEST_GOOGLE_CLOUD_PROJECT_ID = 654321L + const val TEST_REMOTE_ACCESS_CONSUMER_KEY = "13579" + const val TEST_CHALLENGE_VALUE = "__TEST_CHALLENGE_VALUE__" + const val TEST_INTEGRITY_TOKEN = "__TEST_INTEGRITY_TOKEN__" + const val EXPECTED_ATTESTATION_RESULT = + "eyJhdHRlc3RhdGlvbklkIjoiMTIzNDU2IiwiYXR0ZXN0YXRpb25EYXRhIjoiWDE5VVJWTlVYMGxPVkVWSFVrbFVXVjlVVDB0RlRsOWYifQ==" + const val TEST_ATTESTATION_ID = "123456" + const val TEST_ATTESTATION_DATA = "W19VVlJTVVhNbExPVkVWSFVrbFVXVjlVVDB0RlRsOWYifQ==" + const val TEST_ATTESTATION_JSON = + "{ \"attestationId\": \"$TEST_ATTESTATION_ID\", \"attestationData\": \"$TEST_ATTESTATION_DATA\" }" + const val TEST_ATTESTATION_JSON_WITH_UNKNOWN_FIELD = + "{ \"attestationId\": \"$TEST_ATTESTATION_ID\", \"attestationData\": \"$TEST_ATTESTATION_DATA\", \"unknownField\": \"ignored\" }" + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt index 25bdf6d01b..62273eef17 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt @@ -41,6 +41,7 @@ import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getSHA256Hash import com.salesforce.androidsdk.ui.LoginActivity.Companion.ABOUT_BLANK import com.salesforce.androidsdk.ui.LoginViewModel +import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk @@ -69,6 +70,10 @@ import java.net.URI private const val FAKE_SERVER_URL = "shouldMatchNothing.salesforce.com" private const val FAKE_JWT = "1234" private const val FAKE_JWT_FLOW_AUTH = "5678" +private const val TEST_ATTESTATION_SERVER = "test.salesforce.com" +private const val TEST_CHALLENGE_VALUE = "__TEST_CHALLENGE_VALUE__" +private const val TEST_APP_ATTESTATION = "__TEST_APP_ATTESTATION__" +private const val ATTESTATION_QUERY_PARAM_PREFIX = "attestation=" @OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) @@ -572,6 +577,7 @@ class LoginViewModelTest { scopes = listOf("api"), ) } + every { sdkManagerMock.appAttestationClient } returns null val debugConsumerKey = "debug_override_key_789" val debugRedirectUri = "debug://redirect" val debugScopes = listOf("api", "debug_scope") @@ -606,7 +612,7 @@ class LoginViewModelTest { } @Test - fun generateMigrationAuthorizationPath_UsesMigrationConfig_OverAppConfigForLoginHost() { + fun generateMigrationAuthorizationPath_UsesMigrationConfig_OverAppConfigForLoginHost() = runTest { val sdkManagerMock = mockk(relaxed = false) val appConfigConsumerKey = "app_config_key_should_not_be_used" val appConfigRedirectUri = "appconfig://should_not_be_used" @@ -618,6 +624,7 @@ class LoginViewModelTest { scopes = listOf("api"), ) } + every { sdkManagerMock.appAttestationClient } returns null val debugConsumerKey = "debug_override_key_789" val debugRedirectUri = "debug://redirect" val debugScopes = listOf("api", "debug_scope") @@ -871,6 +878,367 @@ class LoginViewModelTest { } } + @Test + fun getAuthorizationUrl_WithNullAppAttestationClient_OmitsAttestationParam() = runBlocking { + val freshViewModel = LoginViewModel(bootConfig) + + val migrationConsumerKey = "migration_override_key_789" + val migrationRedirectUri = "migration://redirect" + val migrationScopes = listOf("api", "migration_scope") + + val loginUrl = freshViewModel.generateMigrationAuthorizationPath( + server = TEST_ATTESTATION_SERVER, + migrationOAuthConfig = OAuthConfig( + migrationConsumerKey, + migrationRedirectUri, + migrationScopes, + ), + ) + + assertFalse( + "URL should NOT contain an attestation parameter but was '$loginUrl'.", + loginUrl.contains(ATTESTATION_QUERY_PARAM_PREFIX), + ) + } + + @Test + fun getAuthorizationUrl_WithAppAttestationClient_IncludesAttestationParam() = runBlocking { + val appAttestationClient = createMockAppAttestationClient(attestation = TEST_APP_ATTESTATION) + val sdkManagerMock = createSdkManagerMockForAttestation(appAttestationClient = appAttestationClient) + val freshViewModel = LoginViewModel(bootConfig) + + val migrationConsumerKey = "migration_override_key_789" + val migrationRedirectUri = "migration://redirect" + val migrationScopes = listOf("api", "migration_scope") + + val loginUrl = freshViewModel.generateMigrationAuthorizationPath( + server = TEST_ATTESTATION_SERVER, + migrationOAuthConfig = OAuthConfig( + migrationConsumerKey, + migrationRedirectUri, + migrationScopes, + ), + sdkManager = sdkManagerMock, + ) + + assertTrue( + "URL should contain '$ATTESTATION_QUERY_PARAM_PREFIX$TEST_APP_ATTESTATION' but was '$loginUrl'.", + loginUrl.contains("$ATTESTATION_QUERY_PARAM_PREFIX$TEST_APP_ATTESTATION"), + ) + coVerify(exactly = 1) { + appAttestationClient.fetchMobileAppAttestationChallenge() + appAttestationClient.createAppAttestation(appAttestationChallenge = TEST_CHALLENGE_VALUE) + } + } + + @Test + fun generateAuthorizationUrl_WhenCreateAppAttestationReturnsNull_OmitsAttestationParam() = runBlocking { + val appAttestationClient = createMockAppAttestationClient(attestation = null) + val sdkManagerMock = createSdkManagerMockForAttestation(appAttestationClient = appAttestationClient) + val freshViewModel = LoginViewModel(bootConfig) + + freshViewModel.generateAuthorizationUrl( + server = TEST_ATTESTATION_SERVER, + sdkManager = sdkManagerMock, + ) + + val loginUrl = freshViewModel.loginUrl.value!! + assertFalse( + "URL should NOT contain an attestation parameter but was '$loginUrl'.", + loginUrl.contains(ATTESTATION_QUERY_PARAM_PREFIX), + ) + coVerify(exactly = 1) { + appAttestationClient.fetchMobileAppAttestationChallenge() + appAttestationClient.createAppAttestation(appAttestationChallenge = TEST_CHALLENGE_VALUE) + } + } + + @Test + fun getAuthorizationUrl_WhenCreateAppAttestationReturnsNull_OmitsAttestationParam() = runBlocking { + val appAttestationClient = createMockAppAttestationClient(attestation = null) + val sdkManagerMock = createSdkManagerMockForAttestation(appAttestationClient = appAttestationClient) + val freshViewModel = LoginViewModel(bootConfig) + + val migrationConsumerKey = "migration_override_key_789" + val migrationRedirectUri = "migration://redirect" + val migrationScopes = listOf("api", "migration_scope") + + val loginUrl = freshViewModel.generateMigrationAuthorizationPath( + server = TEST_ATTESTATION_SERVER, + migrationOAuthConfig = OAuthConfig( + migrationConsumerKey, + migrationRedirectUri, + migrationScopes, + ), + sdkManager = sdkManagerMock, + ) + + assertFalse( + "URL should NOT contain an attestation parameter but was '$loginUrl'.", + loginUrl.contains(ATTESTATION_QUERY_PARAM_PREFIX), + ) + coVerify(exactly = 1) { + appAttestationClient.fetchMobileAppAttestationChallenge() + appAttestationClient.createAppAttestation(appAttestationChallenge = TEST_CHALLENGE_VALUE) + } + } + + @Test + fun generateAuthorizationUrl_WithJwtFlow_IgnoresAdditionalParameters() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.appAttestationClient } returns null + + val freshViewModel = LoginViewModel(bootConfig) + + // Set up JWT flow - both jwt and authCodeForJwtFlow must be non-null/blank + freshViewModel.jwt = FAKE_JWT + freshViewModel.authCodeForJwtFlow = FAKE_JWT_FLOW_AUTH + + // Set additional parameters that should be ignored in JWT flow + freshViewModel.additionalParameters["custom_param"] = "should_not_appear" + + freshViewModel.generateAuthorizationUrl( + server = "test.salesforce.com", + sdkManager = sdkManagerMock, + ) + + val loginUrl = freshViewModel.loginUrl.value!! + + // Verify custom_param from additionalParameters is NOT in the URL + // In JWT flow, additionalParams is set to empty map, ignoring viewModel.additionalParameters + // This covers lines 511 and 514 where jwtFlow is evaluated and mutableMapOf() is used + assertFalse( + "URL should NOT contain custom_param from additionalParameters when JWT flow is active but was '$loginUrl'.", + loginUrl.contains("custom_param"), + ) + } + + @Test + fun generateAuthorizationUrl_WithJwtFlowAndAppAttestation_IncludesAttestationParam() = runBlocking { + val appAttestationClient = createMockAppAttestationClient(attestation = TEST_APP_ATTESTATION) + val sdkManagerMock = createSdkManagerMockForAttestation(appAttestationClient = appAttestationClient) + + val freshViewModel = LoginViewModel(bootConfig) + + // Set up JWT flow - even with JWT flow active, attestation should still be added + freshViewModel.jwt = FAKE_JWT + freshViewModel.authCodeForJwtFlow = FAKE_JWT_FLOW_AUTH + + // Set additional parameters that should be ignored in JWT flow + freshViewModel.additionalParameters["custom_param"] = "should_not_appear" + + freshViewModel.generateAuthorizationUrl( + server = TEST_ATTESTATION_SERVER, + sdkManager = sdkManagerMock, + ) + + val loginUrl = freshViewModel.loginUrl.value!! + + // Verify attestation IS in the URL (even with JWT flow, app attestation adds to additionalParams) + assertTrue( + "URL should contain attestation parameter but was '$loginUrl'.", + loginUrl.contains(ATTESTATION_QUERY_PARAM_PREFIX), + ) + + // Verify custom_param is NOT in URL (JWT flow ignores viewModel.additionalParameters) + // This covers lines 511 and 514 + assertFalse( + "URL should NOT contain custom_param when JWT flow is active but was '$loginUrl'.", + loginUrl.contains("custom_param"), + ) + + // App attestation client should be called (covering the code path) + coVerify(exactly = 1) { + appAttestationClient.fetchMobileAppAttestationChallenge() + appAttestationClient.createAppAttestation(appAttestationChallenge = TEST_CHALLENGE_VALUE) + } + } + + @Test + fun generateMigrationAuthorizationPath_WhenCreateAppAttestationReturnsNull_OmitsAttestationParam() = runBlocking { + val appAttestationClient = createMockAppAttestationClient(attestation = null) + val sdkManagerMock = createSdkManagerMockForAttestation(appAttestationClient = appAttestationClient) + val freshViewModel = LoginViewModel(bootConfig) + + val migrationConsumerKey = "migration_override_key_789" + val migrationRedirectUri = "migration://redirect" + val migrationScopes = listOf("api", "migration_scope") + + val loginUrl = freshViewModel.generateMigrationAuthorizationPath( + server = TEST_ATTESTATION_SERVER, + migrationOAuthConfig = OAuthConfig( + migrationConsumerKey, + migrationRedirectUri, + migrationScopes, + ), + sdkManager = sdkManagerMock, + ) + + assertFalse( + "URL should NOT contain an attestation parameter but was '$loginUrl'.", + loginUrl.contains(ATTESTATION_QUERY_PARAM_PREFIX), + ) + coVerify(exactly = 1) { + appAttestationClient.fetchMobileAppAttestationChallenge() + appAttestationClient.createAppAttestation(appAttestationChallenge = TEST_CHALLENGE_VALUE) + } + } + + @Test + fun generateAuthorizationUrl_WithEmptyJwtString_DoesNotActivateJwtFlow() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.appAttestationClient } returns null + + val freshViewModel = LoginViewModel(bootConfig) + + // Set jwt to empty string - should not activate JWT flow + freshViewModel.jwt = "" + freshViewModel.authCodeForJwtFlow = FAKE_JWT_FLOW_AUTH + + freshViewModel.generateAuthorizationUrl( + server = "test.salesforce.com", + sdkManager = sdkManagerMock, + ) + + val loginUrl = freshViewModel.loginUrl.value!! + + // Verify JWT flow is NOT activated (should use regular OAuth URL, not frontdoor) + assertFalse( + "URL should NOT contain frontdoor path when jwt is empty but was '$loginUrl'.", + loginUrl.contains("/frontdoor.jsp"), + ) + } + + @Test + fun generateAuthorizationUrl_WithBlankJwtString_DoesNotActivateJwtFlow() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.appAttestationClient } returns null + + val freshViewModel = LoginViewModel(bootConfig) + + // Set jwt to blank string (whitespace) - should not activate JWT flow + freshViewModel.jwt = " " + freshViewModel.authCodeForJwtFlow = FAKE_JWT_FLOW_AUTH + + freshViewModel.generateAuthorizationUrl( + server = "test.salesforce.com", + sdkManager = sdkManagerMock, + ) + + val loginUrl = freshViewModel.loginUrl.value!! + + // Verify JWT flow is NOT activated + assertFalse( + "URL should NOT contain frontdoor path when jwt is blank but was '$loginUrl'.", + loginUrl.contains("/frontdoor.jsp"), + ) + } + + @Test + fun generateAuthorizationUrl_WithEmptyAuthCodeString_DoesNotActivateJwtFlow() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.appAttestationClient } returns null + + val freshViewModel = LoginViewModel(bootConfig) + + // Set authCodeForJwtFlow to empty string - should not activate JWT flow + freshViewModel.jwt = FAKE_JWT + freshViewModel.authCodeForJwtFlow = "" + + freshViewModel.generateAuthorizationUrl( + server = "test.salesforce.com", + sdkManager = sdkManagerMock, + ) + + val loginUrl = freshViewModel.loginUrl.value!! + + // Verify JWT flow is NOT activated + assertFalse( + "URL should NOT contain frontdoor path when authCodeForJwtFlow is empty but was '$loginUrl'.", + loginUrl.contains("/frontdoor.jsp"), + ) + } + + @Test + fun generateAuthorizationUrl_WithBlankAuthCodeString_DoesNotActivateJwtFlow() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.appAttestationClient } returns null + + val freshViewModel = LoginViewModel(bootConfig) + + // Set authCodeForJwtFlow to blank string - should not activate JWT flow + freshViewModel.jwt = FAKE_JWT + freshViewModel.authCodeForJwtFlow = " " + + freshViewModel.generateAuthorizationUrl( + server = "test.salesforce.com", + sdkManager = sdkManagerMock, + ) + + val loginUrl = freshViewModel.loginUrl.value!! + + // Verify JWT flow is NOT activated + assertFalse( + "URL should NOT contain frontdoor path when authCodeForJwtFlow is blank but was '$loginUrl'.", + loginUrl.contains("/frontdoor.jsp"), + ) + } + + @Test + fun generateAuthorizationUrl_WithNullAuthCodeString_DoesNotActivateJwtFlow() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.appAttestationClient } returns null + + val freshViewModel = LoginViewModel(bootConfig) + + // Set jwt to valid value but leave authCodeForJwtFlow as null - should not activate JWT flow + freshViewModel.jwt = FAKE_JWT + freshViewModel.authCodeForJwtFlow = null + + freshViewModel.generateAuthorizationUrl( + server = "test.salesforce.com", + sdkManager = sdkManagerMock, + ) + + val loginUrl = freshViewModel.loginUrl.value!! + + // Verify JWT flow is NOT activated + assertFalse( + "URL should NOT contain frontdoor path when authCodeForJwtFlow is null but was '$loginUrl'.", + loginUrl.contains("/frontdoor.jsp"), + ) + } + @Test fun loginViewModel_applyPendingLoginServer_returns_onNullPendingLoginServer() { @@ -1178,6 +1546,25 @@ class LoginViewModelTest { assertEquals(result, viewModel.getValidServerUrl(value)) } + private fun createSdkManagerMockForAttestation( + appAttestationClient: AppAttestationClient?, + ): SalesforceSDKManager = mockk(relaxed = true).also { mock -> + every { mock.useHybridAuthentication } returns false + every { mock.isDebugBuild } returns false + every { mock.debugOverrideAppConfig } returns null + every { mock.appConfigForLoginHost } returns { _ -> null } + every { mock.appAttestationClient } returns appAttestationClient + } + + private fun createMockAppAttestationClient( + attestation: String?, + ): AppAttestationClient = mockk(relaxed = true).also { client -> + every { client.fetchMobileAppAttestationChallenge() } returns TEST_CHALLENGE_VALUE + coEvery { + client.createAppAttestation(appAttestationChallenge = TEST_CHALLENGE_VALUE) + } returns attestation + } + private fun generateExpectedAuthorizationUrl( server: String, codeChallenge: String, diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/NativeLoginManagerTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/NativeLoginManagerTest.kt index 8c6c5e615d..459f1552f8 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/NativeLoginManagerTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/NativeLoginManagerTest.kt @@ -11,18 +11,26 @@ import com.salesforce.androidsdk.accounts.UserAccountBuilder import com.salesforce.androidsdk.accounts.UserAccountManager import com.salesforce.androidsdk.accounts.UserAccountTest import com.salesforce.androidsdk.app.SalesforceSDKManager -import com.salesforce.androidsdk.security.BiometricAuthenticationManager -import com.salesforce.androidsdk.security.BiometricAuthenticationManager.Companion.SHOW_BIOMETRIC +import com.salesforce.androidsdk.auth.OAuth2.OAUTH_AUTH_PATH import com.salesforce.androidsdk.rest.ClientManager import com.salesforce.androidsdk.rest.ClientManager.RestClientCallback import com.salesforce.androidsdk.rest.RestClient import com.salesforce.androidsdk.rest.RestClient.OAuthRefreshInterceptor +import com.salesforce.androidsdk.rest.RestResponse +import com.salesforce.androidsdk.security.BiometricAuthenticationManager +import com.salesforce.androidsdk.security.BiometricAuthenticationManager.Companion.SHOW_BIOMETRIC +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okhttp3.Call import org.junit.After import org.junit.Assert +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -32,6 +40,7 @@ import org.junit.runner.RunWith class NativeLoginManagerTest { private lateinit var mgr: NativeLoginManager private lateinit var bioAuthManager: BiometricAuthenticationManager + @Before fun setUp() { mgr = NativeLoginManager("clientId", "redirect", "loginUrl") @@ -41,6 +50,7 @@ class NativeLoginManagerTest { fun tearDown() { SalesforceSDKManager.getInstance().userAccountManager .signoutCurrentUser(null, true, OAuth2.LogoutReason.USER_LOGOUT) + SalesforceSDKManager.getInstance().appAttestationClient = null unmockkAll() } @@ -101,9 +111,18 @@ class NativeLoginManagerTest { Assert.assertNull("Should not return username when not locked.", mgr.biometricAuthenticationUsername) bioAuthManager.lock() - Assert.assertEquals("Should return username.", "test_username", mgr.biometricAuthenticationUsername) + assertEquals("Should return username.", "test_username", mgr.biometricAuthenticationUsername) } + @Test + fun nativeLoginManager_createRequestBody_filtersNullValues() { + + val result = mgr.createRequestBody("key1" to "value1", "key2" to null) + + val buffer = okio.Buffer() + result.writeTo(buffer) + assertEquals("key1=value1", buffer.readUtf8()) + } @Test fun testPresentBiometricAuthReturnsFalseWhenNotLocked() { @@ -274,13 +293,132 @@ class NativeLoginManagerTest { val account = SalesforceSDKManager.getInstance().userAccountManager.currentUser bioAuthManager.storeMobilePolicy(account, enabled = true, timeout = 15) bioAuthManager.lock() - Assert.assertEquals( + assertEquals( "Should return username for native login user when locked.", "test_username", mgr.biometricAuthenticationUsername ) } + /** + * Tests that native login uses the app attestation during login. This test + * can be removed when a comprehensive test of native login is created so + * long as that test covers the inclusion of the attestation parameter. + */ + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun nativeLoginManager_login_collectsAppAttestation() = runTest { + + installAppAttestationClient(attestation = TEST_APP_ATTESTATION) + val restClient = createRestClientStubbingFailedLoginResponse() + mgr = createNativeLoginManagerForTest(restClient = restClient) + + mgr.login(TEST_USERNAME, TEST_PASSWORD) + advanceUntilIdle() + + verify(exactly = 1) { + restClient.sendAsync(match { + val buffer = okio.Buffer() + it.requestBody.writeTo(buffer) + val bodyString = buffer.readUtf8() + it.path == "$TEST_LOGIN_URL$OAUTH_AUTH_PATH" && + bodyString.contains("attestation=$TEST_APP_ATTESTATION") + }, any()) + } + } + + /** + * Tests that native login does not include app attestation during login + * when the app attestation client is set but + * [AppAttestationClient.createAppAttestation] returns null (for example, + * because the Google Play Integrity API could not produce a token). This + * test can be removed when a comprehensive test of native login is created + * so long as that test covers the exclusion of the attestation parameter. + */ + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun nativeLoginManager_login_doesNotCollectAppAttestationWhenCreateAppAttestationReturnsNull() = runTest { + + installAppAttestationClient(attestation = null) + val restClient = createRestClientStubbingFailedLoginResponse() + mgr = createNativeLoginManagerForTest(restClient = restClient) + + mgr.login(TEST_USERNAME, TEST_PASSWORD) + advanceUntilIdle() + + verify(exactly = 1) { + restClient.sendAsync(match { + val buffer = okio.Buffer() + it.requestBody.writeTo(buffer) + val bodyString = buffer.readUtf8() + it.path == "$TEST_LOGIN_URL$OAUTH_AUTH_PATH" && + !bodyString.contains("attestation=") + }, any()) + } + } + + /** + * Tests that native login does not include app attestation during login + * when it is not applicable. This test can be removed when a comprehensive + * test of native login is created so long as that test covers the exclusion + * of the attestation parameter. + */ + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun nativeLoginManager_login_doesNotCollectAppAttestationWhenAppAttestationClientIsNotSet() = runTest { + + val restClient = createRestClientStubbingFailedLoginResponse() + mgr = createNativeLoginManagerForTest(restClient = restClient) + + mgr.login(TEST_USERNAME, TEST_PASSWORD) + advanceUntilIdle() + + verify(exactly = 1) { + restClient.sendAsync(match { + val buffer = okio.Buffer() + it.requestBody.writeTo(buffer) + val bodyString = buffer.readUtf8() + it.path == "$TEST_LOGIN_URL$OAUTH_AUTH_PATH" && + !bodyString.contains("attestation=") + }, any()) + } + } + + // region Helpers used by attestation tests + + private fun installAppAttestationClient(attestation: String?) { + val appAttestationClient = mockk(relaxed = true).apply { + every { fetchMobileAppAttestationChallenge() } returns TEST_CHALLENGE_VALUE + coEvery { + createAppAttestation(appAttestationChallenge = TEST_CHALLENGE_VALUE) + } returns attestation + } + SalesforceSDKManager.getInstance().appAttestationClient = appAttestationClient + } + + private fun createRestClientStubbingFailedLoginResponse(): RestClient { + val mockResponse = mockk(relaxed = true).apply { + every { isSuccess } returns false + } + return mockk(relaxed = true).apply { + every { sendAsync(any(), any()) } answers { + val callback = secondArg() + callback.onSuccess(firstArg(), mockResponse) + mockk(relaxed = true) + } + } + } + + private fun createNativeLoginManagerForTest(restClient: RestClient): NativeLoginManager = + NativeLoginManager( + clientId = TEST_CLIENT_ID, + redirectUri = TEST_REDIRECT_URI, + loginUrl = TEST_LOGIN_URL, + restClient = restClient, + ) + + // endregion Helpers used by attestation tests + private fun addUserAccount() { UserAccountManager.getInstance().createAccount(UserAccountTest.createTestAccount()) } @@ -292,4 +430,14 @@ class NativeLoginManagerTest { .build() UserAccountManager.getInstance().createAccount(account) } + + private companion object { + const val TEST_CLIENT_ID = "clientId" + const val TEST_REDIRECT_URI = "redirect" + const val TEST_LOGIN_URL = "loginUrl" + const val TEST_USERNAME = "TestUser@Example.com" + const val TEST_PASSWORD = "test123456" + const val TEST_CHALLENGE_VALUE = "__TEST_CHALLENGE_VALUE__" + const val TEST_APP_ATTESTATION = "__TEST_APP_ATTESTATION__" + } } \ No newline at end of file diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2MockTests.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2MockTests.kt new file mode 100644 index 0000000000..f890af29e0 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2MockTests.kt @@ -0,0 +1,218 @@ +package com.salesforce.androidsdk.auth + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.auth.OAuth2.ATTESTATION +import com.salesforce.androidsdk.auth.OAuth2.exchangeCode +import com.salesforce.androidsdk.auth.OAuth2.getAuthorizationUrl +import com.salesforce.androidsdk.auth.OAuth2.makeTokenEndpointRequest +import com.salesforce.androidsdk.auth.OAuth2.swapJWTForTokens +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import okhttp3.FormBody +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.net.URI + +@RunWith(AndroidJUnit4::class) +class OAuth2MockTests { + + @Test + fun oauth2_getAuthorizationUrl_includesAttestationParameterWhenNotNull() { + + val result = getAuthorizationUrl( + true, + false, + URI.create("https://login.example.com"), + "__REMOTE_CONSUMER_KEY__", + "http://app.example.com/callback", + listOf().toTypedArray(), + null, + "__DISPLAY_TYPE__", + "__CODE_CHALLENGE__", + mapOf(ATTESTATION to "__ATTESTATION_TOKEN__") + ) + + assertTrue(result.query.contains("attestation=__ATTESTATION_TOKEN__")) + } + + @Test + fun oauth2_getAuthorizationUrl_excludesAttestationParameterWhenNull() { + + val result = getAuthorizationUrl( + true, + false, + URI.create("https://login.example.com"), + "__REMOTE_CONSUMER_KEY__", + "http://app.example.com/callback", + listOf().toTypedArray(), + null, + "__DISPLAY_TYPE__", + "__CODE_CHALLENGE__", + mapOf(), + ) + + assertFalse(result.query.contains("attestation=__ATTESTATION_TOKEN__")) + } + + @Test + fun oauth2_makeTokenEndpointRequest_includesAttestationParameterWhenNotNull() { + val appAttestationClient = mockk(relaxed = true) { + every { fetchMobileAppAttestationChallenge() } returns "__TEST_CHALLENGE_VALUE__" + every { createAppAttestationBlocking("__TEST_CHALLENGE_VALUE__") } returns "__ATTESTATION_TOKEN__" + } + val salesforceSdkManager = mockk(relaxed = true) { + every { this@mockk.appAttestationClient } returns appAttestationClient + every { deviceId } returns "__DEVICE_ID__" + } + + val responseBody = """{"access_token":"t","instance_url":"https://i","id":"https://i/id/o/u"}""" + .toResponseBody("application/json; charset=utf-8".toMediaType()) + val okHttpResponse = mockk(relaxed = true) { + every { isSuccessful } returns true + every { body } returns responseBody + } + val requestSlot = slot() + val httpAccessor = mockk(relaxed = true) { + every { okHttpClient } returns mockk { + every { newCall(capture(requestSlot)) } returns mockk { + every { execute() } returns okHttpResponse + } + } + } + + makeTokenEndpointRequest( + httpAccessor, + URI.create("https://login.example.com"), + FormBody.Builder(), + salesforceSdkManager, + ) + + val query = requestSlot.captured.url.query ?: "" + assertTrue( + "Expected attestation parameter in request URL but got: $query", + query.contains("attestation=__ATTESTATION_TOKEN__"), + ) + } + + @Test + fun oauth2_makeTokenEndpointRequest_excludesAttestationParameterWhenNull() { + val salesforceSdkManager = mockk(relaxed = true) { + every { this@mockk.appAttestationClient } returns null + every { deviceId } returns "__DEVICE_ID__" + } + + val responseBody = """{"access_token":"t","instance_url":"https://i","id":"https://i/id/o/u"}""" + .toResponseBody("application/json; charset=utf-8".toMediaType()) + val okHttpResponse = mockk(relaxed = true) { + every { isSuccessful } returns true + every { body } returns responseBody + } + val requestSlot = slot() + val httpAccessor = mockk(relaxed = true) { + every { okHttpClient } returns mockk { + every { newCall(capture(requestSlot)) } returns mockk { + every { execute() } returns okHttpResponse + } + } + } + + makeTokenEndpointRequest( + httpAccessor, + URI.create("https://login.example.com"), + FormBody.Builder(), + salesforceSdkManager, + ) + + val query = requestSlot.captured.url.query ?: "" + assertFalse( + "Did not expect attestation parameter in request URL but got: $query", + query.contains("attestation=__ATTESTATION_TOKEN__"), + ) + } + + @Test + fun oauth2_exchangeCode_sendsAuthorizationCodeParameters() { + val responseBody = """{"access_token":"t","instance_url":"https://i","id":"https://i/id/o/u"}""" + .toResponseBody("application/json; charset=utf-8".toMediaType()) + val okHttpResponse = mockk(relaxed = true) { + every { isSuccessful } returns true + every { body } returns responseBody + } + val requestSlot = slot() + val httpAccessor = mockk(relaxed = true) { + every { okHttpClient } returns mockk { + every { newCall(capture(requestSlot)) } returns mockk { + every { execute() } returns okHttpResponse + } + } + } + + exchangeCode( + httpAccessor, + URI.create("https://login.example.com"), + "__REMOTE_CONSUMER_KEY__", + "__AUTH_CODE__", + "__CODE_VERIFIER__", + "http://app.example.com/callback", + ) + + val bodyBuffer = Buffer().also { requestSlot.captured.body?.writeTo(it) } + val formBody = bodyBuffer.readUtf8() + assertTrue( + "Expected client_id=__REMOTE_CONSUMER_KEY__ in form body but got: $formBody", + formBody.contains("client_id=__REMOTE_CONSUMER_KEY__"), + ) + assertTrue( + "Expected code=__AUTH_CODE__ in form body but got: $formBody", + formBody.contains("code=__AUTH_CODE__"), + ) + assertTrue( + "Expected code_verifier=__CODE_VERIFIER__ in form body but got: $formBody", + formBody.contains("code_verifier=__CODE_VERIFIER__"), + ) + } + + @Test + fun oauth2_swapJWTForTokens_sendsJwtBearerGrantTypeAndAssertion() { + val responseBody = """{"access_token":"t","instance_url":"https://i","id":"https://i/id/o/u"}""" + .toResponseBody("application/json; charset=utf-8".toMediaType()) + val okHttpResponse = mockk(relaxed = true) { + every { isSuccessful } returns true + every { body } returns responseBody + } + val requestSlot = slot() + val httpAccessor = mockk(relaxed = true) { + every { okHttpClient } returns mockk { + every { newCall(capture(requestSlot)) } returns mockk { + every { execute() } returns okHttpResponse + } + } + } + + swapJWTForTokens( + httpAccessor, + URI.create("https://login.example.com"), + "__JWT_ASSERTION__", + ) + + val bodyBuffer = Buffer().also { requestSlot.captured.body?.writeTo(it) } + val formBody = bodyBuffer.readUtf8() + assertTrue( + "Expected grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer in form body but got: $formBody", + formBody.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"), + ) + assertTrue( + "Expected assertion=__JWT_ASSERTION__ in form body but got: $formBody", + formBody.contains("assertion=__JWT_ASSERTION__"), + ) + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelperTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelperTest.kt new file mode 100644 index 0000000000..b5075b2e4d --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelperTest.kt @@ -0,0 +1,219 @@ +/* + * 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.auth.idp + +import android.webkit.WebView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.salesforce.androidsdk.accounts.UserAccount +import com.salesforce.androidsdk.auth.AppAttestationClient +import com.salesforce.androidsdk.auth.OAuth2 +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.net.URI + +@Suppress("OPT_IN_USAGE") +@RunWith(AndroidJUnit4::class) +class IDPAuthCodeHelperTest { + + @After + fun tearDown() { + unmockkAll() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun idpAuthCodeHelper_getAuthorizationPathForSP_whenNoAttestationClient_returnsPathAndQueryWithoutAttestation() = runTest { + + val idpAuthCodeHelper = createIdpAuthCodeHelper(appAttestationClient = null) + + val result = idpAuthCodeHelper.getAuthorizationPathForSP() + + advanceUntilIdle() + + val nonNullResult = requireNotNull(result) { + "Result should be non-null for a valid login server." + } + assertTrue( + "Result should start with the OAuth authorize path but was '$nonNullResult'.", + nonNullResult.startsWith(OAUTH_AUTHORIZE_PATH), + ) + assertTrue( + "Result should contain the client id but was '$nonNullResult'.", + nonNullResult.contains("client_id=$TEST_CLIENT_ID"), + ) + assertTrue( + "Result should contain the code challenge but was '$nonNullResult'.", + nonNullResult.contains("code_challenge=$TEST_CODE_CHALLENGE"), + ) + assertTrue( + "Result should contain the redirect URI but was '$nonNullResult'.", + nonNullResult.contains("redirect_uri=$TEST_CALLBACK_URL"), + ) + assertFalse( + "Result should NOT contain an attestation parameter but was '$nonNullResult'.", + nonNullResult.contains("attestation="), + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun idpAuthCodeHelper_getAuthorizationPathForSP_whenAttestationClientReturnsAttestation_includesAttestationInQuery() = runTest { + + val appAttestationClient = createMockAttestationClient(attestation = TEST_APP_ATTESTATION) + val idpAuthCodeHelper = createIdpAuthCodeHelper(appAttestationClient = appAttestationClient) + + val result = idpAuthCodeHelper.getAuthorizationPathForSP() + + advanceUntilIdle() + + val nonNullResult = requireNotNull(result) { + "Result should be non-null for a valid login server." + } + assertTrue( + "Result should contain 'attestation=$TEST_APP_ATTESTATION' but was '$nonNullResult'.", + nonNullResult.contains("attestation=$TEST_APP_ATTESTATION"), + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun idpAuthCodeHelper_getAuthorizationPathForSP_whenCreateAppAttestationReturnsNull_excludesAttestationFromQuery() = runTest { + + val appAttestationClient = createMockAttestationClient(attestation = null) + val idpAuthCodeHelper = createIdpAuthCodeHelper(appAttestationClient = appAttestationClient) + + val result = idpAuthCodeHelper.getAuthorizationPathForSP() + + advanceUntilIdle() + + val nonNullResult = requireNotNull(result) { + "Result should be non-null for a valid login server." + } + assertFalse( + "Result should NOT contain an attestation parameter but was '$nonNullResult'.", + nonNullResult.contains("attestation="), + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun idpAuthCodeHelper_getAuthorizationPathForSP_whenAuthorizationUrlIsNull_returnsNull() = runTest { + + stubOAuthAuthorizationUrl(returnValue = null) + val idpAuthCodeHelper = createIdpAuthCodeHelper(appAttestationClient = null) + + val result = idpAuthCodeHelper.getAuthorizationPathForSP() + + advanceUntilIdle() + + assertNull("Result should be null when OAuth2.getAuthorizationUrl returns null.", result) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun idpAuthCodeHelper_getAuthorizationPathForSP_whenAuthorizationUrlHasNoQuery_returnsPathOnly() = runTest { + + stubOAuthAuthorizationUrl(returnValue = URI("$TEST_LOGIN_SERVER$OAUTH_AUTHORIZE_PATH")) + val idpAuthCodeHelper = createIdpAuthCodeHelper(appAttestationClient = null) + + val result = idpAuthCodeHelper.getAuthorizationPathForSP() + + advanceUntilIdle() + + assertEquals(OAUTH_AUTHORIZE_PATH, result) + } + + // region Helpers + + private fun createSPConfig(): SPConfig = SPConfig( + appPackageName = TEST_SP_APP_PACKAGE, + componentName = TEST_SP_COMPONENT_NAME, + oauthClientId = TEST_CLIENT_ID, + oauthCallbackUrl = TEST_CALLBACK_URL, + oauthScopes = TEST_SCOPES, + ) + + private fun createMockUserAccount(): UserAccount = mockk(relaxed = true).apply { + every { loginServer } returns TEST_LOGIN_SERVER + } + + private fun createMockAttestationClient(attestation: String?): AppAttestationClient = + mockk(relaxed = true).apply { + every { fetchMobileAppAttestationChallenge() } returns TEST_CHALLENGE_VALUE + coEvery { + createAppAttestation(appAttestationChallenge = TEST_CHALLENGE_VALUE) + } returns attestation + } + + private fun createIdpAuthCodeHelper( + appAttestationClient: AppAttestationClient?, + ): IDPAuthCodeHelper = IDPAuthCodeHelper( + webView = mockk(relaxed = true), + userAccount = createMockUserAccount(), + spConfig = createSPConfig(), + codeChallenge = TEST_CODE_CHALLENGE, + onResult = { /* no-op */ }, + appAttestationClient = appAttestationClient, + ) + + private fun stubOAuthAuthorizationUrl(returnValue: URI?) { + mockkStatic(OAuth2::class) + every { + OAuth2.getAuthorizationUrl( + any(), any(), any(), any(), any(), any(), any(), any(), any(), + ) + } returns returnValue + } + + // endregion Helpers + + private companion object { + const val TEST_LOGIN_SERVER = "https://login.example.com" + const val TEST_CLIENT_ID = "__TEST_CLIENT_ID__" + const val TEST_CALLBACK_URL = "sfdc://callback" + const val TEST_CODE_CHALLENGE = "__TEST_CODE_CHALLENGE__" + const val TEST_CHALLENGE_VALUE = "__TEST_CHALLENGE_VALUE__" + const val TEST_APP_ATTESTATION = "__TEST_APP_ATTESTATION__" + const val TEST_SP_APP_PACKAGE = "com.example.sp" + const val TEST_SP_COMPONENT_NAME = "com.example.sp.MainActivity" + const val OAUTH_AUTHORIZE_PATH = "/services/oauth2/authorize" + val TEST_SCOPES = arrayOf("api") + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt index b753f58a22..98cd985528 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt @@ -71,6 +71,7 @@ class ClientManagerMockTest { every { deviceId } returns "test-device-id-123" every { additionalOauthKeys } returns emptyList() every { useHybridAuthentication } returns true + every { appAttestationClient } returns null every { appContext } returns mockAppContext every { isDevSupportEnabled() } returns true } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java index 8da4abc8bd..a0f641a367 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java @@ -761,7 +761,7 @@ public void testQueryWithBatchSize() throws Exception { /** * Testing that calling resume more than once on a RestResponse doesn't throw an exception - * @throws Exception + * @throws Exception */ @Test public void testDoubleConsume() throws Exception {