Skip to content

Commit 39de947

Browse files
authored
Adds functionality for creating Low Scoped Access Token
* ⏺ Add AuthenticationSecureTokenServiceAPI to fetch low scoped access token - Add headers parameter to APIRequest for Basic auth support - Convert PayPalWebCheckoutClient.start() to suspend function - Integrate token fetching before launching web checkout - Add comprehensive error handling for token fetch failures * ⏺ Add callback-based start function with PayPalWebCheckoutCallback interface - Create PayPalWebCheckoutCallback fun interface following CardApproveOrderCallback pattern - Add overloaded start() function accepting PayPalWebCheckoutCallback
1 parent 9d77a9e commit 39de947

File tree

9 files changed

+441
-45
lines changed

9 files changed

+441
-45
lines changed

CorePayments/src/main/java/com/paypal/android/corepayments/APIClientError.kt

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import androidx.annotation.RestrictTo
99
object APIClientError {
1010

1111
// 0. An unknown error occurred.
12-
fun unknownError(correlationId: String?) = PayPalSDKError(
12+
fun unknownError(correlationId: String? = null, throwable: Throwable? = null) = PayPalSDKError(
1313
code = PayPalSDKErrorCode.UNKNOWN.ordinal,
1414
errorDescription = "An unknown error occurred. Contact developer.paypal.com/support.",
15+
reason = throwable,
1516
correlationId = correlationId
1617
)
1718

@@ -37,12 +38,6 @@ object APIClientError {
3738
correlationId = correlationId
3839
)
3940

40-
// 4. There was an error constructing the URLRequest.
41-
val invalidUrlRequest = PayPalSDKError(
42-
code = PayPalSDKErrorCode.INVALID_URL_REQUEST.ordinal,
43-
errorDescription = "An error occurred constructing an HTTP request. Contact developer.paypal.com/support."
44-
)
45-
4641
// 5. The server's response body returned an error message.
4742
fun serverResponseError(correlationId: String?) = PayPalSDKError(
4843
code = PayPalSDKErrorCode.SERVER_RESPONSE_ERROR.ordinal,
@@ -58,28 +53,6 @@ object APIClientError {
5853
correlationId = correlationId
5954
)
6055

61-
val payPalCheckoutError: (description: String) -> PayPalSDKError = { description ->
62-
PayPalSDKError(
63-
code = PayPalSDKErrorCode.CHECKOUT_ERROR.ordinal,
64-
errorDescription = description
65-
)
66-
}
67-
68-
val payPalNativeCheckoutError: (description: String, reason: Exception) -> PayPalSDKError =
69-
{ description, reason ->
70-
PayPalSDKError(
71-
code = PayPalSDKErrorCode.NATIVE_CHECKOUT_ERROR.ordinal,
72-
errorDescription = description,
73-
reason = reason
74-
)
75-
}
76-
77-
fun clientIDNotFoundError(code: Int, correlationId: String?) = PayPalSDKError(
78-
code = code,
79-
errorDescription = "Error fetching clientId. Contact developer.paypal.com/support.",
80-
correlationId = correlationId
81-
)
82-
8356
fun graphQLJSONParseError(correlationId: String?, reason: Exception): PayPalSDKError {
8457
val message =
8558
"An error occurred while parsing the GraphQL response JSON. Contact developer.paypal.com/support."

CorePayments/src/main/java/com/paypal/android/corepayments/APIRequest.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,9 @@ import androidx.annotation.RestrictTo
66
* @suppress
77
*/
88
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
9-
data class APIRequest(val path: String, val method: HttpMethod, val body: String? = null)
9+
data class APIRequest(
10+
val path: String,
11+
val method: HttpMethod,
12+
val body: String? = null,
13+
val headers: Map<String, String>? = null
14+
)

CorePayments/src/main/java/com/paypal/android/corepayments/PayPalSDKErrorCode.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ enum class PayPalSDKErrorCode {
1515
SERVER_RESPONSE_ERROR,
1616
CHECKOUT_ERROR,
1717
NATIVE_CHECKOUT_ERROR,
18-
GRAPHQL_JSON_INVALID_ERROR
18+
GRAPHQL_JSON_INVALID_ERROR,
19+
NO_ACCESS_TOKEN_ERROR,
1920
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.paypal.android.corepayments.api
2+
3+
import androidx.annotation.RestrictTo
4+
import com.paypal.android.corepayments.APIClientError
5+
import com.paypal.android.corepayments.APIRequest
6+
import com.paypal.android.corepayments.CoreConfig
7+
import com.paypal.android.corepayments.HttpMethod
8+
import com.paypal.android.corepayments.PayPalSDKError
9+
import com.paypal.android.corepayments.PayPalSDKErrorCode
10+
import com.paypal.android.corepayments.RestClient
11+
import com.paypal.android.corepayments.base64encoded
12+
import org.json.JSONObject
13+
14+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
15+
class AuthenticationSecureTokenServiceAPI(
16+
private val coreConfig: CoreConfig,
17+
private val restClient: RestClient = RestClient(configuration = coreConfig),
18+
) {
19+
suspend fun createLowScopedAccessToken(): CreateLowScopedAccessTokenResult {
20+
val requestBody = "grant_type=client_credentials&response_type=token"
21+
22+
val headers = mutableMapOf(
23+
"Authorization" to "Basic ${coreConfig.clientId.base64encoded()}:",
24+
"Content-Type" to "application/x-www-form-urlencoded"
25+
)
26+
27+
val apiRequest = APIRequest(
28+
path = "v1/oauth2/token",
29+
method = HttpMethod.POST,
30+
body = requestBody,
31+
headers = headers
32+
)
33+
34+
return runCatching {
35+
val httpResponse = restClient.send(apiRequest)
36+
val correlationId = httpResponse.headers["paypal-debug-id"]
37+
if (httpResponse.isSuccessful && httpResponse.body != null) {
38+
val jsonObject = JSONObject(httpResponse.body)
39+
val token = jsonObject.optString("access_token")
40+
if (token.isNotEmpty()) {
41+
CreateLowScopedAccessTokenResult.Success(token)
42+
} else {
43+
val error = PayPalSDKError(
44+
code = PayPalSDKErrorCode.NO_ACCESS_TOKEN_ERROR.ordinal,
45+
errorDescription = "Missing access_token in response",
46+
correlationId = correlationId
47+
)
48+
CreateLowScopedAccessTokenResult.Failure(error)
49+
}
50+
} else {
51+
val error = httpResponse.run {
52+
PayPalSDKError(
53+
code = status,
54+
errorDescription = body ?: "Unknown error",
55+
correlationId = correlationId
56+
)
57+
}
58+
CreateLowScopedAccessTokenResult.Failure(error)
59+
}
60+
}.getOrElse { throwable ->
61+
val error = APIClientError.unknownError(throwable = throwable)
62+
CreateLowScopedAccessTokenResult.Failure(error)
63+
}
64+
}
65+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.paypal.android.corepayments.api
2+
3+
import androidx.annotation.RestrictTo
4+
import com.paypal.android.corepayments.PayPalSDKError
5+
6+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
7+
sealed class CreateLowScopedAccessTokenResult {
8+
/**
9+
* The request to get client token was successful.
10+
*
11+
* @property token the access token from the response
12+
*/
13+
data class Success(val token: String) : CreateLowScopedAccessTokenResult()
14+
15+
/**
16+
* There was an error with the request to get client token.
17+
*
18+
* @property error the error that occurred
19+
*/
20+
data class Failure(val error: PayPalSDKError) : CreateLowScopedAccessTokenResult()
21+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package com.paypal.android.corepayments.api
2+
3+
import com.paypal.android.corepayments.APIRequest
4+
import com.paypal.android.corepayments.CoreConfig
5+
import com.paypal.android.corepayments.Environment
6+
import com.paypal.android.corepayments.HttpMethod
7+
import com.paypal.android.corepayments.HttpResponse
8+
import com.paypal.android.corepayments.RestClient
9+
import io.mockk.coEvery
10+
import io.mockk.coVerify
11+
import io.mockk.mockk
12+
import io.mockk.slot
13+
import junit.framework.TestCase.assertEquals
14+
import junit.framework.TestCase.assertTrue
15+
import kotlinx.coroutines.ExperimentalCoroutinesApi
16+
import kotlinx.coroutines.test.runTest
17+
import org.junit.Before
18+
import org.junit.Test
19+
import org.junit.runner.RunWith
20+
import org.robolectric.RobolectricTestRunner
21+
22+
@ExperimentalCoroutinesApi
23+
@RunWith(RobolectricTestRunner::class)
24+
class AuthenticationSecureTokenServiceAPIUnitTest {
25+
26+
private lateinit var restClient: RestClient
27+
private lateinit var coreConfig: CoreConfig
28+
private lateinit var sut: AuthenticationSecureTokenServiceAPI
29+
30+
@Before
31+
fun beforeEach() {
32+
coreConfig = CoreConfig("test-client-id", Environment.SANDBOX)
33+
restClient = mockk(relaxed = true)
34+
sut = AuthenticationSecureTokenServiceAPI(coreConfig, restClient)
35+
}
36+
37+
@Test
38+
fun `createLowScopedAccessToken() makes correct API request with proper headers and body`() =
39+
runTest {
40+
// Given
41+
val successResponse = HttpResponse(
42+
status = 200,
43+
body = """{"access_token": "test-token", "token_type": "Bearer", "expires_in": 3600}"""
44+
)
45+
coEvery { restClient.send(any()) } returns successResponse
46+
47+
val requestSlot = slot<APIRequest>()
48+
49+
// When
50+
val result = sut.createLowScopedAccessToken()
51+
52+
// Then
53+
coVerify { restClient.send(capture(requestSlot)) }
54+
55+
val capturedRequest = requestSlot.captured
56+
assertEquals("v1/oauth2/token", capturedRequest.path)
57+
assertEquals(HttpMethod.POST, capturedRequest.method)
58+
assertEquals("grant_type=client_credentials&response_type=token", capturedRequest.body)
59+
60+
// Verify headers
61+
val headers = capturedRequest.headers!!
62+
assertEquals("application/x-www-form-urlencoded", headers["Content-Type"])
63+
assert(headers["Authorization"]!!.startsWith("Basic "))
64+
65+
assertTrue(result is CreateLowScopedAccessTokenResult.Success)
66+
assertEquals("test-token", (result as CreateLowScopedAccessTokenResult.Success).token)
67+
}
68+
69+
@Test
70+
fun `createLowScopedAccessToken() returns access token from successful response`() = runTest {
71+
// Given
72+
val expectedToken = "test-access-token-12345"
73+
val successResponse = HttpResponse(
74+
status = 200,
75+
body = """{"access_token": "$expectedToken", "token_type": "Bearer", "expires_in": 3600}"""
76+
)
77+
coEvery { restClient.send(any()) } returns successResponse
78+
79+
// When
80+
val result = sut.createLowScopedAccessToken()
81+
82+
// Then
83+
assertTrue(result is CreateLowScopedAccessTokenResult.Success)
84+
assertEquals(expectedToken, (result as CreateLowScopedAccessTokenResult.Success).token)
85+
}
86+
87+
@Test
88+
fun `createLowScopedAccessToken() returns failure when response is not successful`() = runTest {
89+
// Given
90+
val errorResponse = HttpResponse(
91+
status = 401,
92+
body = """{"error": "invalid_client", "error_description": "Client authentication failed"}"""
93+
)
94+
coEvery { restClient.send(any()) } returns errorResponse
95+
96+
// When
97+
val result = sut.createLowScopedAccessToken()
98+
99+
// Then
100+
assertTrue(result is CreateLowScopedAccessTokenResult.Failure)
101+
}
102+
103+
@Test
104+
fun `createLowScopedAccessToken() returns failure when response body is null`() = runTest {
105+
// Given
106+
val successResponse = HttpResponse(
107+
status = 200,
108+
body = null
109+
)
110+
coEvery { restClient.send(any()) } returns successResponse
111+
112+
// When
113+
val result = sut.createLowScopedAccessToken()
114+
115+
// Then
116+
assertTrue(result is CreateLowScopedAccessTokenResult.Failure)
117+
}
118+
119+
@Test
120+
fun `createLowScopedAccessToken() returns failure when response is not valid JSON`() = runTest {
121+
// Given
122+
val successResponse = HttpResponse(
123+
status = 200,
124+
body = "invalid-json-response"
125+
)
126+
coEvery { restClient.send(any()) } returns successResponse
127+
128+
// When
129+
val result = sut.createLowScopedAccessToken()
130+
131+
// Then
132+
assertTrue(result is CreateLowScopedAccessTokenResult.Failure)
133+
// Expected JSONException wrapped in PayPalSDKError
134+
}
135+
136+
@Test
137+
fun `createLowScopedAccessToken() returns failure when access_token field is missing from successful response`() =
138+
runTest {
139+
// Given
140+
val successResponse = HttpResponse(
141+
status = 200,
142+
body = """{"token_type": "Bearer", "expires_in": 3600}"""
143+
)
144+
coEvery { restClient.send(any()) } returns successResponse
145+
146+
// When
147+
val result = sut.createLowScopedAccessToken()
148+
149+
// Then
150+
assertTrue(result is CreateLowScopedAccessTokenResult.Failure)
151+
val error = (result as CreateLowScopedAccessTokenResult.Failure).error
152+
assertTrue(error.errorDescription.contains("Missing access_token in response"))
153+
}
154+
155+
@Test
156+
fun `createLowScopedAccessToken() handles empty response body with proper error message`() =
157+
runTest {
158+
// Given
159+
val errorResponse = HttpResponse(
160+
status = 500,
161+
body = ""
162+
)
163+
coEvery { restClient.send(any()) } returns errorResponse
164+
165+
// When
166+
val result = sut.createLowScopedAccessToken()
167+
168+
// Then
169+
assertTrue(result is CreateLowScopedAccessTokenResult.Failure)
170+
val errorMessage =
171+
(result as CreateLowScopedAccessTokenResult.Failure).error.errorDescription
172+
// The error description will be the empty body from the HTTP response
173+
assertEquals("", errorMessage)
174+
}
175+
176+
@Test
177+
fun `createLowScopedAccessToken() includes error message in exception when available`() =
178+
runTest {
179+
// Given
180+
val errorMessage = "Server temporarily unavailable"
181+
val errorResponse = HttpResponse(
182+
status = 503,
183+
body = errorMessage
184+
)
185+
coEvery { restClient.send(any()) } returns errorResponse
186+
187+
// When
188+
val result = sut.createLowScopedAccessToken()
189+
190+
// Then
191+
assertTrue(result is CreateLowScopedAccessTokenResult.Failure)
192+
val description =
193+
(result as CreateLowScopedAccessTokenResult.Failure).error.errorDescription
194+
// The error description will be the error message from the HTTP response body
195+
assertEquals(errorMessage, description)
196+
}
197+
198+
@Test
199+
fun `createLowScopedAccessToken() creates proper Basic Auth header from client ID`() = runTest {
200+
// Given
201+
val clientId = "test-client-123"
202+
val configWithCustomClientId = CoreConfig(clientId, Environment.LIVE)
203+
val sutWithCustomConfig =
204+
AuthenticationSecureTokenServiceAPI(configWithCustomClientId, restClient)
205+
206+
val successResponse = HttpResponse(
207+
status = 200,
208+
body = """{"access_token": "token", "token_type": "Bearer"}"""
209+
)
210+
coEvery { restClient.send(any()) } returns successResponse
211+
212+
val requestSlot = slot<APIRequest>()
213+
214+
// When
215+
sutWithCustomConfig.createLowScopedAccessToken()
216+
217+
// Then
218+
coVerify { restClient.send(capture(requestSlot)) }
219+
220+
val authHeader = requestSlot.captured.headers!!["Authorization"]!!
221+
assert(authHeader.startsWith("Basic "))
222+
223+
// Decode and verify the Basic auth contains the client ID
224+
val encodedCredentials = authHeader.substring("Basic ".length)
225+
val decodedClientId =
226+
android.util.Base64.decode(encodedCredentials, android.util.Base64.DEFAULT)
227+
.decodeToString()
228+
assertEquals(clientId, decodedClientId)
229+
}
230+
}

0 commit comments

Comments
 (0)