Skip to content

Commit

Permalink
Add CustomerSessionClientSecret validation & remodel `ElementsSessi…
Browse files Browse the repository at this point in the history
…onManager` response. (#9373)

* Add `CustomerSessionClientSecret` validation & remodel `ElementsSessionManager` response.

* Address PR comments
  • Loading branch information
samer-stripe authored Oct 1, 2024
1 parent 292d709 commit c89b6d5
Show file tree
Hide file tree
Showing 9 changed files with 256 additions and 136 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,10 @@ interface ErrorReporter : FraudDetectionErrorReporter {
),
CUSTOMER_SHEET_ATTACH_CALLED_WITH_CUSTOMER_SESSION(
partialEventName = "customersheet.customer_session.attach_called"
)
),
CUSTOMER_SESSION_ON_CUSTOMER_SHEET_ELEMENTS_SESSION_NO_CUSTOMER_FIELD(
partialEventName = "customersheet.customer_session.elements_session.no_customer_field"
),
;

override val eventName: String
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.stripe.android.common.validation

internal object CustomerSessionClientSecretValidator {
private const val EPHEMERAL_KEY_SECRET_PREFIX = "ek_"
private const val CUSTOMER_SESSION_CLIENT_SECRET_KEY_PREFIX = "cuss_"

sealed interface Result {
data object Valid : Result

sealed interface Error : Result {
data object Empty : Result

data object LegacyEphemeralKey : Result

data object UnknownKey : Result
}
}

fun validate(customerSessionClientSecret: String): Result {
return when {
customerSessionClientSecret.isBlank() ->
Result.Error.Empty
customerSessionClientSecret.startsWith(EPHEMERAL_KEY_SECRET_PREFIX) ->
Result.Error.LegacyEphemeralKey
!customerSessionClientSecret.startsWith(CUSTOMER_SESSION_CLIENT_SECRET_KEY_PREFIX) ->
Result.Error.UnknownKey
else -> Result.Valid
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,13 @@ package com.stripe.android.customersheet.data
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes

internal sealed interface CachedCustomerEphemeralKey {
fun shouldRefresh(currentTimeInMillis: Long): Boolean

data class Available(
val customerId: String,
val ephemeralKey: String,
private val expiresAt: Int,
) : CachedCustomerEphemeralKey {
override fun shouldRefresh(currentTimeInMillis: Long): Boolean {
val remainingTime = expiresAt - currentTimeInMillis.milliseconds.inWholeSeconds
return remainingTime <= 5.minutes.inWholeSeconds
}
}

data object None : CachedCustomerEphemeralKey {
override fun shouldRefresh(currentTimeInMillis: Long): Boolean {
return true
}
internal data class CachedCustomerEphemeralKey(
val customerId: String,
val ephemeralKey: String,
private val expiresAt: Int,
) {
fun shouldRefresh(currentTimeInMillis: Long): Boolean {
val remainingTime = expiresAt - currentTimeInMillis.milliseconds.inWholeSeconds
return remainingTime <= 5.minutes.inWholeSeconds
}
}
Original file line number Diff line number Diff line change
@@ -1,61 +1,61 @@
package com.stripe.android.customersheet.data

import com.stripe.android.common.validation.CustomerSessionClientSecretValidator
import com.stripe.android.core.injection.IOContext
import com.stripe.android.customersheet.CustomerSheet
import com.stripe.android.customersheet.ExperimentalCustomerSheetApi
import com.stripe.android.model.ElementsSession
import com.stripe.android.payments.core.analytics.ErrorReporter
import com.stripe.android.paymentsheet.ExperimentalCustomerSessionApi
import com.stripe.android.paymentsheet.PaymentSheet
import com.stripe.android.paymentsheet.PrefsRepository
import com.stripe.android.paymentsheet.model.SavedSelection
import com.stripe.android.paymentsheet.repositories.ElementsSessionRepository
import kotlinx.coroutines.withContext
import java.lang.IllegalArgumentException
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.CoroutineContext

internal interface CustomerSessionElementsSessionManager {
suspend fun fetchCustomerSessionEphemeralKey(): Result<CachedCustomerEphemeralKey.Available>
suspend fun fetchCustomerSessionEphemeralKey(): Result<CachedCustomerEphemeralKey>

suspend fun fetchElementsSession(): Result<ElementsSession>
suspend fun fetchElementsSession(): Result<CustomerSessionElementsSession>
}

internal data class CustomerSessionElementsSession(
val elementsSession: ElementsSession,
val customer: ElementsSession.Customer,
val ephemeralKey: CachedCustomerEphemeralKey,
)

@OptIn(ExperimentalCustomerSheetApi::class, ExperimentalCustomerSessionApi::class)
@Singleton
internal class DefaultCustomerSessionElementsSessionManager @Inject constructor(
private val elementsSessionRepository: ElementsSessionRepository,
private val prefsRepositoryFactory: @JvmSuppressWildcards (String) -> PrefsRepository,
private val customerSessionProvider: CustomerSheet.CustomerSessionProvider,
private val errorReporter: ErrorReporter,
private val timeProvider: () -> Long,
@IOContext private val workContext: CoroutineContext,
) : CustomerSessionElementsSessionManager {
@Volatile
private var cachedCustomerEphemeralKey: CachedCustomerEphemeralKey = CachedCustomerEphemeralKey.None
private var cachedCustomerEphemeralKey: CachedCustomerEphemeralKey? = null

private var intentConfiguration: CustomerSheet.IntentConfiguration? = null

override suspend fun fetchCustomerSessionEphemeralKey(): Result<CachedCustomerEphemeralKey.Available> {
override suspend fun fetchCustomerSessionEphemeralKey(): Result<CachedCustomerEphemeralKey> {
return withContext(workContext) {
runCatching {
val ephemeralKey = cachedCustomerEphemeralKey.takeUnless { cachedCustomerEphemeralKey ->
cachedCustomerEphemeralKey.shouldRefresh(timeProvider())
} ?: run {
fetchElementsSession().getOrThrow()

cachedCustomerEphemeralKey
}

when (ephemeralKey) {
is CachedCustomerEphemeralKey.Available -> ephemeralKey
is CachedCustomerEphemeralKey.None -> throw IllegalStateException(
"No ephemeral key available!"
)
}
cachedCustomerEphemeralKey.takeUnless { cachedCustomerEphemeralKey ->
cachedCustomerEphemeralKey == null ||
cachedCustomerEphemeralKey.shouldRefresh(timeProvider())
} ?: fetchElementsSession().getOrThrow().ephemeralKey
}
}
}

override suspend fun fetchElementsSession(): Result<ElementsSession> {
override suspend fun fetchElementsSession(): Result<CustomerSessionElementsSession> {
return withContext(workContext) {
runCatching {
val intentConfiguration = intentConfiguration
Expand All @@ -67,6 +67,8 @@ internal class DefaultCustomerSessionElementsSessionManager @Inject constructor(
.providesCustomerSessionClientSecret()
.getOrThrow()

validateCustomerSessionClientSecret(customerSessionClientSecret.clientSecret)

val prefsRepository = prefsRepositoryFactory(customerSessionClientSecret.customerId)

val savedSelection = prefsRepository.getSavedSelection(
Expand All @@ -87,16 +89,58 @@ internal class DefaultCustomerSessionElementsSessionManager @Inject constructor(
clientSecret = customerSessionClientSecret.clientSecret,
),
externalPaymentMethods = listOf(),
).onSuccess { elementsSession ->
elementsSession.customer?.session?.run {
cachedCustomerEphemeralKey = CachedCustomerEphemeralKey.Available(
customerId = customerId,
ephemeralKey = apiKey,
expiresAt = apiKeyExpiry,
).mapCatching { elementsSession ->
val customer = elementsSession.customer ?: run {
errorReporter.report(
ErrorReporter
.UnexpectedErrorEvent
.CUSTOMER_SESSION_ON_CUSTOMER_SHEET_ELEMENTS_SESSION_NO_CUSTOMER_FIELD
)

throw IllegalStateException(
"`customer` field should be available when using `CustomerSession` in elements/session!"
)
}

val customerSession = customer.session

CustomerSessionElementsSession(
elementsSession = elementsSession,
customer = customer,
ephemeralKey = CachedCustomerEphemeralKey(
customerId = customerSession.customerId,
ephemeralKey = customerSession.apiKey,
expiresAt = customerSession.apiKeyExpiry,
)
)
}.onSuccess { customerSessionElementsSession ->
cachedCustomerEphemeralKey = customerSessionElementsSession.ephemeralKey
}.getOrThrow()
}
}
}

private fun validateCustomerSessionClientSecret(customerSessionClientSecret: String) {
val result = CustomerSessionClientSecretValidator
.validate(customerSessionClientSecret)

val error = when (result) {
is CustomerSessionClientSecretValidator.Result.Error.Empty -> {
"The provided 'customerSessionClientSecret' cannot be an empty string."
}
is CustomerSessionClientSecretValidator.Result.Error.LegacyEphemeralKey -> {
"Provided secret looks like an Ephemeral Key secret, but expecting a CustomerSession client " +
"secret. See CustomerSession API: https://docs.stripe.com/api/customer_sessions/create"
}
is CustomerSessionClientSecretValidator.Result.Error.UnknownKey -> {
"Provided secret does not look like a CustomerSession client secret. " +
"See CustomerSession API: https://docs.stripe.com/api/customer_sessions/create"
}
is CustomerSessionClientSecretValidator.Result.Valid -> null
}

error?.let {
throw IllegalArgumentException(error)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,8 @@ internal class CustomerSessionPaymentMethodDataSource @Inject constructor(
) : CustomerSheetPaymentMethodDataSource {
override suspend fun retrievePaymentMethods(): CustomerSheetDataResult<List<PaymentMethod>> {
return withContext(workContext) {
elementsSessionManager.fetchElementsSession().mapCatching { elementsSession ->
elementsSession.customer?.paymentMethods
?: throw IllegalStateException(
"`CustomerSession` should contain payment methods in `elements/session` response!"
)
elementsSessionManager.fetchElementsSession().mapCatching { elementsSessionWithCustomer ->
elementsSessionWithCustomer.customer.paymentMethods
}.toCustomerSheetDataResult()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp
import com.stripe.android.common.validation.CustomerSessionClientSecretValidator
import com.stripe.android.model.CardBrand
import com.stripe.android.paymentsheet.addresselement.AddressDetails
import com.stripe.android.uicore.PrimaryButtonColors
Expand All @@ -13,9 +14,6 @@ import com.stripe.android.uicore.StripeTheme
import com.stripe.android.uicore.StripeThemeDefaults
import java.lang.IllegalArgumentException

private const val EPHEMERAL_KEY_SECRET_PREFIX = "ek_"
private const val CUSTOMER_SESSION_CLIENT_SECRET_KEY_PREFIX = "cuss_"

internal fun PaymentSheet.Configuration.validate() {
// These are not localized as they are not intended to be displayed to a user.
when {
Expand Down Expand Up @@ -44,23 +42,29 @@ internal fun PaymentSheet.Configuration.validate() {
}
}
is PaymentSheet.CustomerAccessType.CustomerSession -> {
val customerSessionClientSecret = customerAccessType.customerSessionClientSecret
val result = CustomerSessionClientSecretValidator
.validate(customerAccessType.customerSessionClientSecret)

if (customerSessionClientSecret.isBlank()) {
throw IllegalArgumentException(
"When a CustomerConfiguration is passed to PaymentSheet, " +
"the customerSessionClientSecret cannot be an empty string."
)
} else if (customerSessionClientSecret.startsWith(EPHEMERAL_KEY_SECRET_PREFIX)) {
throw IllegalArgumentException(
"Argument looks like an Ephemeral Key secret, but expecting a CustomerSession client " +
"secret. See CustomerSession API: https://docs.stripe.com/api/customer_sessions/create"
)
} else if (!customerSessionClientSecret.startsWith(CUSTOMER_SESSION_CLIENT_SECRET_KEY_PREFIX)) {
throw IllegalArgumentException(
"Argument does not look like a CustomerSession client secret. " +
"See CustomerSession API: https://docs.stripe.com/api/customer_sessions/create"
)
when (result) {
is CustomerSessionClientSecretValidator.Result.Error.Empty -> {
throw IllegalArgumentException(
"When a CustomerConfiguration is passed to PaymentSheet, " +
"the customerSessionClientSecret cannot be an empty string."
)
}
is CustomerSessionClientSecretValidator.Result.Error.LegacyEphemeralKey -> {
throw IllegalArgumentException(
"Argument looks like an Ephemeral Key secret, but expecting a CustomerSession client " +
"secret. See CustomerSession API: https://docs.stripe.com/api/customer_sessions/create"
)
}
is CustomerSessionClientSecretValidator.Result.Error.UnknownKey -> {
throw IllegalArgumentException(
"Argument does not look like a CustomerSession client secret. " +
"See CustomerSession API: https://docs.stripe.com/api/customer_sessions/create"
)
}
is CustomerSessionClientSecretValidator.Result.Valid -> Unit
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@ package com.stripe.android.customersheet.data

import com.google.common.truth.Truth.assertThat
import com.stripe.android.isInstanceOf
import com.stripe.android.model.ElementsSession
import com.stripe.android.model.PaymentMethod
import com.stripe.android.model.PaymentMethodUpdateParams
import com.stripe.android.payments.core.analytics.ErrorReporter
import com.stripe.android.paymentsheet.repositories.CustomerRepository
import com.stripe.android.testing.FakeErrorReporter
import com.stripe.android.testing.PaymentMethodFactory
import com.stripe.android.testing.SetupIntentFactory
import com.stripe.android.utils.FakeCustomerRepository
import kotlinx.coroutines.test.runTest
import org.junit.Test
Expand All @@ -34,38 +32,6 @@ class CustomerSessionPaymentMethodDataSourceTest {
assertThat(returnedPaymentMethods).containsExactlyElementsIn(paymentMethods)
}

@Test
fun `on fetch payment methods, should fail if elements session does not have payment methods`() = runTest {
val paymentMethodDataSource = createPaymentMethodDataSource(
elementsSessionManager = FakeCustomerSessionElementsSessionManager(
elementsSession = Result.success(
ElementsSession(
linkSettings = null,
paymentMethodSpecs = null,
stripeIntent = SetupIntentFactory.create(),
merchantCountry = null,
isGooglePayEnabled = true,
sessionsError = null,
externalPaymentMethodData = null,
customer = null,
cardBrandChoice = null,
)
)
),
)

val paymentMethodsResult = paymentMethodDataSource.retrievePaymentMethods()

assertThat(paymentMethodsResult).isInstanceOf<CustomerSheetDataResult.Failure<List<PaymentMethod>>>()

val failedResult = paymentMethodsResult.asFailure()

assertThat(failedResult.cause).isInstanceOf<IllegalStateException>()
assertThat(failedResult.cause.message).isEqualTo(
"`CustomerSession` should contain payment methods in `elements/session` response!"
)
}

@Test
fun `on fetch payment methods, should fail if elements session fetch fails`() = runTest {
val exception = IllegalStateException("Failed to load!")
Expand Down Expand Up @@ -120,7 +86,7 @@ class CustomerSessionPaymentMethodDataSourceTest {
customerRepository = customerRepository,
elementsSessionManager = FakeCustomerSessionElementsSessionManager(
ephemeralKey = Result.success(
CachedCustomerEphemeralKey.Available(
CachedCustomerEphemeralKey(
customerId = "cus_1",
ephemeralKey = "ek_123",
expiresAt = 999999,
Expand Down Expand Up @@ -203,7 +169,7 @@ class CustomerSessionPaymentMethodDataSourceTest {
customerRepository = customerRepository,
elementsSessionManager = FakeCustomerSessionElementsSessionManager(
ephemeralKey = Result.success(
CachedCustomerEphemeralKey.Available(
CachedCustomerEphemeralKey(
customerId = "cus_1",
ephemeralKey = "ek_123",
expiresAt = 999999,
Expand Down
Loading

0 comments on commit c89b6d5

Please sign in to comment.