Skip to content

Commit 484e620

Browse files
@W-21933885: [MSDK Android] App Attestation Implementation (Limit Integrity Token Provider Retry To 1)
1 parent 48f98cc commit 484e620

2 files changed

Lines changed: 70 additions & 3 deletions

File tree

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,18 @@ class AppAttestationClient(
144144
* External Client App (ECA) Plug-In "Challenge" to use
145145
* @param integrityTokenProvider The Google Play App Integrity API Integrity
146146
* Token Provider. This parameter is intended for testing purposes only
147+
* @param canRetryOnInvalidTokenProvider When true (the default), a single
148+
* inline retry with a freshly prepared Integrity Token Provider is allowed
149+
* if the request fails with [INTEGRITY_TOKEN_PROVIDER_INVALID]. The
150+
* recursive retry call sets this false to guarantee at most one retry
151+
* and prevent unbounded recursion on the caller thread
147152
* @return The "attestation" value usable in Salesforce OAuth authorization
148153
* and token refresh requests or null if the value cannot be created
149154
*/
150155
suspend fun createAppAttestation(
151156
appAttestationChallenge: String,
152157
integrityTokenProvider: StandardIntegrityTokenProvider? = this.integrityTokenProvider,
158+
canRetryOnInvalidTokenProvider: Boolean = true,
153159
): String? {
154160
// Guard to ensure the Google Play Integrity API Integrity Provider was asynchronously resolved or do so synchronously now.
155161
val integrityTokenProviderResolved = integrityTokenProvider ?: prepareIntegrityTokenProvider().await()
@@ -185,10 +191,12 @@ class AppAttestationClient(
185191
).toBase64String()
186192
}.getOrElse { e ->
187193
// If the Google Play Integrity API failed due to the Integrity Token Provider being expired, re-prepare it once for an inline retry.
188-
if ((e as? IntegrityServiceException)?.errorCode == INTEGRITY_TOKEN_PROVIDER_INVALID) {
194+
// The retry call passes canRetryOnInvalidTokenProvider = false to cap retries at one attempt and prevent unbounded recursion on the caller thread if the freshly prepared provider also reports INTEGRITY_TOKEN_PROVIDER_INVALID.
195+
if (canRetryOnInvalidTokenProvider && (e as? IntegrityServiceException)?.errorCode == INTEGRITY_TOKEN_PROVIDER_INVALID) {
189196
createAppAttestation(
190197
appAttestationChallenge = appAttestationChallenge,
191-
integrityTokenProvider = null
198+
integrityTokenProvider = null,
199+
canRetryOnInvalidTokenProvider = false,
192200
)
193201
} else {
194202
null
@@ -248,7 +256,14 @@ internal data class OAuthAuthorizationAttestation(
248256
) {
249257

250258
/**
251-
* Returns a Base64-encoded JSON representation of this object
259+
* Returns a Base64-encoded JSON representation of this object.
260+
*
261+
* Note: Standard Base64 alphabet with padding is used by design. The
262+
* Salesforce App Attestation server-side contract requires the
263+
* standard (not URL-safe) Base64 encoding with padding, and the value
264+
* is consumed as-is without URI percent-encoding at the token endpoint
265+
* (see OAuth2.makeTokenEndpointRequest). This has been verified
266+
* end-to-end; do not switch to Base64.getUrlEncoder() or strip padding.
252267
*/
253268
fun toBase64String(): String? = Base64.getEncoder().encodeToString(Json.encodeToString(serializer(), this).encodeToByteArray())
254269
}

libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AppAttestationClientTest.kt

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,58 @@ class AppAttestationClientTest {
141141
assertEquals(EXPECTED_ATTESTATION_RESULT, result)
142142
}
143143

144+
@OptIn(ExperimentalCoroutinesApi::class)
145+
@Test
146+
fun appAttestationClient_createAppAttestation_whenBothProvidersThrowInvalidTokenProvider_retriesAtMostOnceAndReturnsNull() = runTest {
147+
148+
val throwingIntegrityTokenProvider = createThrowingIntegrityTokenProvider(
149+
throwable = createIntegrityServiceException(errorCode = INTEGRITY_TOKEN_PROVIDER_INVALID),
150+
)
151+
val integrityManager = createMockIntegrityManagerResolvingTo(
152+
provider = createThrowingIntegrityTokenProvider(
153+
throwable = createIntegrityServiceException(errorCode = INTEGRITY_TOKEN_PROVIDER_INVALID),
154+
),
155+
)
156+
val appAttestationClient = createAppAttestationClientForTest(integrityManager = integrityManager)
157+
158+
val result = appAttestationClient.createAppAttestation(
159+
appAttestationChallenge = TEST_CHALLENGE_VALUE,
160+
integrityTokenProvider = throwingIntegrityTokenProvider,
161+
)
162+
163+
advanceUntilIdle()
164+
165+
assertNull(result)
166+
// integrityManager.prepareIntegrityToken is called exactly twice: once from the constructor's init {} block,
167+
// and exactly once more for the single inline retry. A count > 2 would indicate unbounded recursion.
168+
verify(exactly = 2) { integrityManager.prepareIntegrityToken(any()) }
169+
}
170+
171+
@OptIn(ExperimentalCoroutinesApi::class)
172+
@Test
173+
fun appAttestationClient_createAppAttestation_whenCanRetryIsFalseAndProviderThrowsInvalid_shortCircuitsWithoutRetry() = runTest {
174+
175+
val throwingIntegrityTokenProvider = createThrowingIntegrityTokenProvider(
176+
throwable = createIntegrityServiceException(errorCode = INTEGRITY_TOKEN_PROVIDER_INVALID),
177+
)
178+
val integrityManager = createMockIntegrityManagerResolvingTo(
179+
provider = createSuccessfulIntegrityTokenProvider(),
180+
)
181+
val appAttestationClient = createAppAttestationClientForTest(integrityManager = integrityManager)
182+
183+
val result = appAttestationClient.createAppAttestation(
184+
appAttestationChallenge = TEST_CHALLENGE_VALUE,
185+
integrityTokenProvider = throwingIntegrityTokenProvider,
186+
canRetryOnInvalidTokenProvider = false,
187+
)
188+
189+
advanceUntilIdle()
190+
191+
assertNull(result)
192+
// Only the constructor's init {} block may call prepareIntegrityToken; no retry is allowed when canRetryOnInvalidTokenProvider = false.
193+
verify(exactly = 1) { integrityManager.prepareIntegrityToken(any()) }
194+
}
195+
144196
@OptIn(ExperimentalCoroutinesApi::class)
145197
@Test
146198
fun appAttestationClient_createSalesforceOAuthAuthorizationAppAttestationThrowingForUnknownIntegrityServiceException_returnsSuccessfully() = runTest {

0 commit comments

Comments
 (0)