Skip to content

Commit 7e5c233

Browse files
@W-21933885: [MSDK Android] App Attestation Implementation (Automated Implementation Of IDPAuthCodeHelperTest.kt)
1 parent b8f1790 commit 7e5c233

2 files changed

Lines changed: 227 additions & 4 deletions

File tree

libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ import android.net.Uri
3030
import android.webkit.WebResourceRequest
3131
import android.webkit.WebView
3232
import android.webkit.WebViewClient
33+
import androidx.annotation.VisibleForTesting
3334
import com.salesforce.androidsdk.R
3435
import com.salesforce.androidsdk.accounts.UserAccount
3536
import com.salesforce.androidsdk.app.SalesforceSDKManager
37+
import com.salesforce.androidsdk.auth.AppAttestationClient
3638
import com.salesforce.androidsdk.auth.OAuth2.ATTESTATION
3739
import com.salesforce.androidsdk.auth.OAuth2.FRONTDOOR_URL_KEY
3840
import com.salesforce.androidsdk.auth.OAuth2.getAuthorizationUrl
@@ -51,12 +53,13 @@ import java.net.URI
5153
/**
5254
* Helper class used in IDP app to get auth code from server
5355
*/
54-
internal class IDPAuthCodeHelper private constructor(
56+
internal class IDPAuthCodeHelper @VisibleForTesting internal constructor(
5557
val webView: WebView,
5658
val userAccount: UserAccount,
5759
val spConfig: SPConfig,
5860
val codeChallenge: String,
59-
val onResult:(result:Result) -> Unit
61+
val onResult: (result: Result) -> Unit,
62+
val appAttestationClient: AppAttestationClient? = SalesforceSDKManager.getInstance().appAttestationClient,
6063
) {
6164
data class Result(
6265
val success: Boolean,
@@ -104,13 +107,14 @@ internal class IDPAuthCodeHelper private constructor(
104107
* Compute relative path of authorization url for SP
105108
* @return authorization relative path
106109
*/
107-
private suspend fun getAuthorizationPathForSP(): String? {
110+
@VisibleForTesting
111+
internal suspend fun getAuthorizationPathForSP(): String? {
108112
SalesforceSDKLogger.d(TAG, "Getting authorization url")
109113
val context = SalesforceSDKManager.getInstance().appContext
110114
val useHybridAuthentication = SalesforceSDKManager.getInstance().useHybridAuthentication
111115

112116
// Add Salesforce Mobile App Attestation parameter to authorization URL if applicable.
113-
val additionalParams = SalesforceSDKManager.getInstance().appAttestationClient?.run {
117+
val additionalParams = appAttestationClient?.run {
114118
val challenge = fetchMobileAppAttestationChallenge()
115119
val attestation = createAppAttestation(challenge) ?: return@run null
116120
mapOf(ATTESTATION to attestation)
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/*
2+
* Copyright (c) 2026-present, salesforce.com, inc.
3+
* All rights reserved.
4+
* Redistribution and use of this software in source and binary forms, with or
5+
* without modification, are permitted provided that the following conditions
6+
* are met:
7+
* - Redistributions of source code must retain the above copyright notice, this
8+
* list of conditions and the following disclaimer.
9+
* - Redistributions in binary form must reproduce the above copyright notice,
10+
* this list of conditions and the following disclaimer in the documentation
11+
* and/or other materials provided with the distribution.
12+
* - Neither the name of salesforce.com, inc. nor the names of its contributors
13+
* may be used to endorse or promote products derived from this software without
14+
* specific prior written permission of salesforce.com, inc.
15+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
18+
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
19+
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
20+
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
21+
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
22+
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
23+
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
24+
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
25+
* POSSIBILITY OF SUCH DAMAGE.
26+
*/
27+
package com.salesforce.androidsdk.auth.idp
28+
29+
import android.webkit.WebView
30+
import androidx.test.ext.junit.runners.AndroidJUnit4
31+
import com.salesforce.androidsdk.accounts.UserAccount
32+
import com.salesforce.androidsdk.auth.AppAttestationClient
33+
import com.salesforce.androidsdk.auth.OAuth2
34+
import io.mockk.coEvery
35+
import io.mockk.every
36+
import io.mockk.mockk
37+
import io.mockk.mockkStatic
38+
import io.mockk.unmockkAll
39+
import kotlinx.coroutines.ExperimentalCoroutinesApi
40+
import kotlinx.coroutines.test.advanceUntilIdle
41+
import kotlinx.coroutines.test.runTest
42+
import org.junit.After
43+
import org.junit.Assert.assertEquals
44+
import org.junit.Assert.assertFalse
45+
import org.junit.Assert.assertNull
46+
import org.junit.Assert.assertTrue
47+
import org.junit.Test
48+
import org.junit.runner.RunWith
49+
import java.net.URI
50+
51+
@Suppress("OPT_IN_USAGE")
52+
@RunWith(AndroidJUnit4::class)
53+
class IDPAuthCodeHelperTest {
54+
55+
@After
56+
fun tearDown() {
57+
unmockkAll()
58+
}
59+
60+
@OptIn(ExperimentalCoroutinesApi::class)
61+
@Test
62+
fun idpAuthCodeHelper_getAuthorizationPathForSP_whenNoAttestationClient_returnsPathAndQueryWithoutAttestation() = runTest {
63+
64+
val idpAuthCodeHelper = createIdpAuthCodeHelper(appAttestationClient = null)
65+
66+
val result = idpAuthCodeHelper.getAuthorizationPathForSP()
67+
68+
advanceUntilIdle()
69+
70+
val nonNullResult = requireNotNull(result) {
71+
"Result should be non-null for a valid login server."
72+
}
73+
assertTrue(
74+
"Result should start with the OAuth authorize path but was '$nonNullResult'.",
75+
nonNullResult.startsWith(OAUTH_AUTHORIZE_PATH),
76+
)
77+
assertTrue(
78+
"Result should contain the client id but was '$nonNullResult'.",
79+
nonNullResult.contains("client_id=$TEST_CLIENT_ID"),
80+
)
81+
assertTrue(
82+
"Result should contain the code challenge but was '$nonNullResult'.",
83+
nonNullResult.contains("code_challenge=$TEST_CODE_CHALLENGE"),
84+
)
85+
assertTrue(
86+
"Result should contain the redirect URI but was '$nonNullResult'.",
87+
nonNullResult.contains("redirect_uri=$TEST_CALLBACK_URL"),
88+
)
89+
assertFalse(
90+
"Result should NOT contain an attestation parameter but was '$nonNullResult'.",
91+
nonNullResult.contains("attestation="),
92+
)
93+
}
94+
95+
@OptIn(ExperimentalCoroutinesApi::class)
96+
@Test
97+
fun idpAuthCodeHelper_getAuthorizationPathForSP_whenAttestationClientReturnsAttestation_includesAttestationInQuery() = runTest {
98+
99+
val appAttestationClient = createMockAttestationClient(attestation = TEST_APP_ATTESTATION)
100+
val idpAuthCodeHelper = createIdpAuthCodeHelper(appAttestationClient = appAttestationClient)
101+
102+
val result = idpAuthCodeHelper.getAuthorizationPathForSP()
103+
104+
advanceUntilIdle()
105+
106+
val nonNullResult = requireNotNull(result) {
107+
"Result should be non-null for a valid login server."
108+
}
109+
assertTrue(
110+
"Result should contain 'attestation=$TEST_APP_ATTESTATION' but was '$nonNullResult'.",
111+
nonNullResult.contains("attestation=$TEST_APP_ATTESTATION"),
112+
)
113+
}
114+
115+
@OptIn(ExperimentalCoroutinesApi::class)
116+
@Test
117+
fun idpAuthCodeHelper_getAuthorizationPathForSP_whenCreateAppAttestationReturnsNull_excludesAttestationFromQuery() = runTest {
118+
119+
val appAttestationClient = createMockAttestationClient(attestation = null)
120+
val idpAuthCodeHelper = createIdpAuthCodeHelper(appAttestationClient = appAttestationClient)
121+
122+
val result = idpAuthCodeHelper.getAuthorizationPathForSP()
123+
124+
advanceUntilIdle()
125+
126+
val nonNullResult = requireNotNull(result) {
127+
"Result should be non-null for a valid login server."
128+
}
129+
assertFalse(
130+
"Result should NOT contain an attestation parameter but was '$nonNullResult'.",
131+
nonNullResult.contains("attestation="),
132+
)
133+
}
134+
135+
@OptIn(ExperimentalCoroutinesApi::class)
136+
@Test
137+
fun idpAuthCodeHelper_getAuthorizationPathForSP_whenAuthorizationUrlIsNull_returnsNull() = runTest {
138+
139+
stubOAuthAuthorizationUrl(returnValue = null)
140+
val idpAuthCodeHelper = createIdpAuthCodeHelper(appAttestationClient = null)
141+
142+
val result = idpAuthCodeHelper.getAuthorizationPathForSP()
143+
144+
advanceUntilIdle()
145+
146+
assertNull("Result should be null when OAuth2.getAuthorizationUrl returns null.", result)
147+
}
148+
149+
@OptIn(ExperimentalCoroutinesApi::class)
150+
@Test
151+
fun idpAuthCodeHelper_getAuthorizationPathForSP_whenAuthorizationUrlHasNoQuery_returnsPathOnly() = runTest {
152+
153+
stubOAuthAuthorizationUrl(returnValue = URI("$TEST_LOGIN_SERVER$OAUTH_AUTHORIZE_PATH"))
154+
val idpAuthCodeHelper = createIdpAuthCodeHelper(appAttestationClient = null)
155+
156+
val result = idpAuthCodeHelper.getAuthorizationPathForSP()
157+
158+
advanceUntilIdle()
159+
160+
assertEquals(OAUTH_AUTHORIZE_PATH, result)
161+
}
162+
163+
// region Helpers
164+
165+
private fun createSPConfig(): SPConfig = SPConfig(
166+
appPackageName = TEST_SP_APP_PACKAGE,
167+
componentName = TEST_SP_COMPONENT_NAME,
168+
oauthClientId = TEST_CLIENT_ID,
169+
oauthCallbackUrl = TEST_CALLBACK_URL,
170+
oauthScopes = TEST_SCOPES,
171+
)
172+
173+
private fun createMockUserAccount(): UserAccount = mockk<UserAccount>(relaxed = true).apply {
174+
every { loginServer } returns TEST_LOGIN_SERVER
175+
}
176+
177+
private fun createMockAttestationClient(attestation: String?): AppAttestationClient =
178+
mockk<AppAttestationClient>(relaxed = true).apply {
179+
every { fetchMobileAppAttestationChallenge() } returns TEST_CHALLENGE_VALUE
180+
coEvery {
181+
createAppAttestation(appAttestationChallenge = TEST_CHALLENGE_VALUE)
182+
} returns attestation
183+
}
184+
185+
private fun createIdpAuthCodeHelper(
186+
appAttestationClient: AppAttestationClient?,
187+
): IDPAuthCodeHelper = IDPAuthCodeHelper(
188+
webView = mockk<WebView>(relaxed = true),
189+
userAccount = createMockUserAccount(),
190+
spConfig = createSPConfig(),
191+
codeChallenge = TEST_CODE_CHALLENGE,
192+
onResult = { /* no-op */ },
193+
appAttestationClient = appAttestationClient,
194+
)
195+
196+
private fun stubOAuthAuthorizationUrl(returnValue: URI?) {
197+
mockkStatic(OAuth2::class)
198+
every {
199+
OAuth2.getAuthorizationUrl(
200+
any(), any(), any(), any(), any(), any(), any(), any(), any(),
201+
)
202+
} returns returnValue
203+
}
204+
205+
// endregion Helpers
206+
207+
private companion object {
208+
const val TEST_LOGIN_SERVER = "https://login.example.com"
209+
const val TEST_CLIENT_ID = "__TEST_CLIENT_ID__"
210+
const val TEST_CALLBACK_URL = "sfdc://callback"
211+
const val TEST_CODE_CHALLENGE = "__TEST_CODE_CHALLENGE__"
212+
const val TEST_CHALLENGE_VALUE = "__TEST_CHALLENGE_VALUE__"
213+
const val TEST_APP_ATTESTATION = "__TEST_APP_ATTESTATION__"
214+
const val TEST_SP_APP_PACKAGE = "com.example.sp"
215+
const val TEST_SP_COMPONENT_NAME = "com.example.sp.MainActivity"
216+
const val OAUTH_AUTHORIZE_PATH = "/services/oauth2/authorize"
217+
val TEST_SCOPES = arrayOf("api")
218+
}
219+
}

0 commit comments

Comments
 (0)