Skip to content

Commit

Permalink
[FC] Updates lookup call to use mobile endpoint on verified flows (#9820
Browse files Browse the repository at this point in the history
)

# Summary

- Uses new `mobile/lookup` on verified flows.
  - Pass verification token and app_id, required for this endpoint
  - Also `session_id` and `email_source`, optional before, are now required. 
- Non verified flows continue to use `POST consumer_sessions` (does some method renaming to match this)
- Modifies `ErrorHandler` to, on attestation failures, close the native AuthFlow and continue on web.

# Motivation
https://docs.google.com/document/d/1joKz5UZHLVazmecfMHbq6gB6n4wj5u8To6AtqYgq_tc/edit?tab=t.0#heading=h.cz1xkpga7giy

# Testing
- [x] Added tests
- [x] Modified tests
- [x] Manually verified
  • Loading branch information
carlosmuvi-stripe authored Jan 15, 2025
1 parent eb9f87e commit 96e9ba3
Show file tree
Hide file tree
Showing 23 changed files with 335 additions and 56 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -442,9 +442,7 @@ enum class Merchant(
Networking("networking"),
LiveTesting("live_testing", canSwitchBetweenTestAndLive = false),
TestMode("testmode", canSwitchBetweenTestAndLive = false),
NmeDefaultVerification("nme", canSwitchBetweenTestAndLive = true),
NmeABAVVerification("nme_abav", canSwitchBetweenTestAndLive = true),
NmeSkipVerification("nme_skip", canSwitchBetweenTestAndLive = true),
Trusted("trusted", canSwitchBetweenTestAndLive = false),
Custom("other");

companion object {
Expand Down
1 change: 1 addition & 0 deletions financial-connections/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<ID>LongMethod:AccountItem.kt$@Composable @Preview internal fun AccountItemPreview()</ID>
<ID>LongMethod:Button.kt$@Composable internal fun FinancialConnectionsButton( onClick: () -> Unit, modifier: Modifier = Modifier, type: Type = Primary, size: FinancialConnectionsButton.Size = FinancialConnectionsButton.Size.Regular, enabled: Boolean = true, loading: Boolean = false, content: @Composable (RowScope.() -> Unit) )</ID>
<ID>LongMethod:FinancialConnectionsSheetNativeActivity.kt$FinancialConnectionsSheetNativeActivity$@Composable fun NavHost( initialPane: Pane, testMode: Boolean, )</ID>
<ID>LongMethod:FinancialConnectionsSheetNativeViewModel.kt$FinancialConnectionsSheetNativeViewModel$private fun closeAuthFlow( earlyTerminationCause: EarlyTerminationCause? = null, closeAuthFlowError: Throwable? = null )</ID>
<ID>LongMethod:InstitutionPickerScreen.kt$private fun LazyListScope.searchResults( isInputEmpty: Boolean, payload: Payload, selectedInstitutionId: String?, onInstitutionSelected: (FinancialConnectionsInstitution, Boolean) -> Unit, institutions: Async&lt;InstitutionResponse>, onManualEntryClick: () -> Unit, onSearchMoreClick: () -> Unit )</ID>
<ID>LongMethod:LinkAccountPickerPreviewParameterProvider.kt$LinkAccountPickerPreviewParameterProvider$private fun partnerAccountList()</ID>
<ID>LongMethod:NetworkingSaveToLinkVerificationScreen.kt$@Composable private fun NetworkingSaveToLinkVerificationLoaded( confirmVerificationAsync: Async&lt;Unit>, payload: Payload, onCloseFromErrorClick: (Throwable) -> Unit, onSkipClick: () -> Unit, )</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.stripe.android.financialconnections.debug.DebugConfiguration
import com.stripe.android.financialconnections.di.FinancialConnectionsSingletonSharedComponentHolder
import com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity

/**
Expand All @@ -20,7 +21,9 @@ class FinancialConnectionsSheetRedirectActivity : AppCompatActivity() {
*/
intent.data
?.let { uri ->
val uriWithDebugConfiguration = uri.overrideWithDebugConfiguration()
val uriWithDebugConfiguration = uri
.overrideWithDebugConfiguration()
.overrideIfIntegrityFailed()
uriWithDebugConfiguration.toIntent()
?.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
?.also { it.data = uriWithDebugConfiguration }
Expand Down Expand Up @@ -67,6 +70,21 @@ class FinancialConnectionsSheetRedirectActivity : AppCompatActivity() {
null -> this
}

/**
* When an integrity verdict fails, clients will switch to the web flow locally but backend will still
* consider the flow native. This checks the local verdict state and overrides native deep links to web.
*/
private fun Uri.overrideIfIntegrityFailed(): Uri =
when (
FinancialConnectionsSingletonSharedComponentHolder
.getComponent(application)
.integrityVerdictManager()
.verdictFailed()
) {
true -> Uri.parse(toString().replace(HOST_NATIVE_LINK_ACCOUNTS, HOST_LINK_ACCOUNTS))
else -> this
}

private fun Uri.isFinancialConnectionsScheme(): Boolean {
return (this.scheme == "stripe-auth" || this.scheme == "stripe")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ import com.stripe.android.financialconnections.domain.FetchFinancialConnectionsS
import com.stripe.android.financialconnections.domain.FetchFinancialConnectionsSessionForToken
import com.stripe.android.financialconnections.domain.GetOrFetchSync
import com.stripe.android.financialconnections.domain.GetOrFetchSync.RefetchCondition.Always
import com.stripe.android.financialconnections.domain.IntegrityVerdictManager
import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator
import com.stripe.android.financialconnections.domain.NativeAuthFlowRouter
import com.stripe.android.financialconnections.exception.AppInitializationError
import com.stripe.android.financialconnections.exception.CustomManualEntryRequiredError
import com.stripe.android.financialconnections.features.error.isAttestationError
import com.stripe.android.financialconnections.features.manualentry.isCustomManualEntryError
import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetActivityArgs
import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetActivityArgs.ForData
Expand Down Expand Up @@ -71,6 +73,7 @@ internal class FinancialConnectionsSheetViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val getOrFetchSync: GetOrFetchSync,
private val integrityRequestManager: IntegrityRequestManager,
private val integrityVerdictManager: IntegrityVerdictManager,
private val fetchFinancialConnectionsSession: FetchFinancialConnectionsSession,
private val fetchFinancialConnectionsSessionForToken: FetchFinancialConnectionsSessionForToken,
private val logger: Logger,
Expand Down Expand Up @@ -130,6 +133,11 @@ internal class FinancialConnectionsSheetViewModel @Inject constructor(
}

private suspend fun prepareStandardRequestManager(): Boolean {
// If previously within the application session an integrity check failed
// do not initialize the request manager and directly launch the web flow.
if (integrityVerdictManager.verdictFailed()) {
return false
}
val result = integrityRequestManager.prepare()
result.onFailure {
analyticsTracker.track(
Expand Down Expand Up @@ -525,6 +533,11 @@ internal class FinancialConnectionsSheetViewModel @Inject constructor(
fromNative: Boolean = false,
@StringRes finishMessage: Int? = null,
) {
if (result is Failed && result.error.isAttestationError) {
integrityVerdictManager.setVerdictFailed()
switchToWebFlow()
return
}
eventReporter.onResult(state.initialArgs.configuration, result)
// Native emits its own events before finishing.
if (fromNative.not()) {
Expand All @@ -540,6 +553,35 @@ internal class FinancialConnectionsSheetViewModel @Inject constructor(
setState { copy(viewEffect = FinishWithResult(result, finishMessage)) }
}

/**
* On scenarios where native failed mid flow due to attestation errors, switch back to web flow.
*/
private fun switchToWebFlow() {
viewModelScope.launch {
val sync = getOrFetchSync()
val hostedAuthUrl = HostedAuthUrlBuilder.create(
args = initialState.initialArgs,
manifest = sync.manifest,
)

if (hostedAuthUrl != null) {
setState {
copy(
manifest = manifest,
// Use intermediate state to prevent the flow from closing in [onResume].
webAuthFlowStatus = AuthFlowStatus.INTERMEDIATE_DEEPLINK,
viewEffect = OpenAuthFlowWithUrl(hostedAuthUrl)
)
}
} else {
finishWithResult(
state = stateFlow.value,
result = Failed(IllegalArgumentException("hostedAuthUrl is required to switch to web flow!"))
)
}
}
}

companion object {

val Factory = viewModelFactory {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.stripe.android.financialconnections.di

import android.app.Application
import com.stripe.android.core.Logger
import com.stripe.android.financialconnections.domain.IntegrityVerdictManager
import com.stripe.attestation.BuildConfig
import com.stripe.attestation.IntegrityRequestManager
import com.stripe.attestation.IntegrityStandardRequestManager
Expand Down Expand Up @@ -38,7 +39,9 @@ internal object FinancialConnectionsSingletonSharedComponentHolder {
@Component(modules = [FinancialConnectionsSingletonSharedModule::class])
internal interface FinancialConnectionsSingletonSharedComponent {

fun providesIntegrityRequestManager(): IntegrityRequestManager
fun integrityRequestManager(): IntegrityRequestManager

fun integrityVerdictManager(): IntegrityVerdictManager

@Component.Factory
interface Factory {
Expand All @@ -58,4 +61,8 @@ internal class FinancialConnectionsSingletonSharedModule {
logError = { message, error -> Logger.getInstance(BuildConfig.DEBUG).error(message, error) },
factory = RealStandardIntegrityManagerFactory(context)
)

@Provides
@Singleton
fun providesIntegrityVerdictManager(): IntegrityVerdictManager = IntegrityVerdictManager()
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ package com.stripe.android.financialconnections.domain
import com.stripe.android.core.Logger
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker
import com.stripe.android.financialconnections.analytics.logError
import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator.Message.CloseWithError
import com.stripe.android.financialconnections.features.error.isAttestationError
import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest
import com.stripe.android.financialconnections.navigation.Destination
import com.stripe.android.financialconnections.navigation.NavigationManager
import com.stripe.android.financialconnections.repository.FinancialConnectionsErrorRepository
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import javax.inject.Inject

internal interface HandleError {
Expand All @@ -21,6 +25,7 @@ internal interface HandleError {
internal class RealHandleError @Inject constructor(
private val errorRepository: FinancialConnectionsErrorRepository,
private val analyticsTracker: FinancialConnectionsAnalyticsTracker,
private val nativeAuthFlowCoordinator: NativeAuthFlowCoordinator,
private val logger: Logger,
private val navigationManager: NavigationManager
) : HandleError {
Expand All @@ -42,7 +47,7 @@ internal class RealHandleError @Inject constructor(
extraMessage: String,
error: Throwable,
pane: FinancialConnectionsSessionManifest.Pane,
displayErrorScreen: Boolean
displayErrorScreen: Boolean,
) {
analyticsTracker.logError(
extraMessage = extraMessage,
Expand All @@ -51,8 +56,15 @@ internal class RealHandleError @Inject constructor(
pane = pane
)

// Navigate to error screen
if (displayErrorScreen) {
if (error.isAttestationError) {
/*
An attestation error (verification token generation error, unsatisfactory attestation verdict, etc)
Happened mid flow -> Close the native flow with the error (right after we'll open a web browser to finish
the flow)
*/
GlobalScope.launch { nativeAuthFlowCoordinator().emit(CloseWithError(cause = error)) }
} else if (displayErrorScreen) {
// Navigate to error screen
errorRepository.set(error)
navigationManager.tryNavigateTo(route = Destination.Error(referrer = pane))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.stripe.android.financialconnections.domain

/**
* Manages the verdict of the integrity check. If the verdict is failed, the user will be switched to web flow.
*
* The scope of this is the application session. Subsequent launches of the AuthFlow within the hosting app after
* a verdict failure will directly launch the web flow.
*/
internal class IntegrityVerdictManager {

private var verdictFailed: Boolean = false

fun setVerdictFailed() {
verdictFailed = true
}

fun verdictFailed(): Boolean {
return verdictFailed
}
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,43 @@
package com.stripe.android.financialconnections.domain

import android.app.Application
import com.stripe.android.financialconnections.FinancialConnectionsSheet
import com.stripe.android.financialconnections.repository.FinancialConnectionsConsumerSessionRepository
import com.stripe.android.model.ConsumerSessionLookup
import com.stripe.android.model.EmailSource
import com.stripe.attestation.IntegrityRequestManager
import javax.inject.Inject

internal class LookupAccount @Inject constructor(
private val application: Application,
private val integrityRequestManager: IntegrityRequestManager,
private val consumerSessionRepository: FinancialConnectionsConsumerSessionRepository,
val configuration: FinancialConnectionsSheet.Configuration,
) {

suspend operator fun invoke(
email: String
): ConsumerSessionLookup = requireNotNull(
consumerSessionRepository.lookupConsumerSession(
email = email.lowercase().trim(),
clientSecret = configuration.financialConnectionsSessionClientSecret
)
)
email: String,
emailSource: EmailSource,
verifiedFlow: Boolean,
sessionId: String
): ConsumerSessionLookup {
return if (verifiedFlow) {
requireNotNull(
consumerSessionRepository.mobileLookupConsumerSession(
email = email.lowercase().trim(),
emailSource = emailSource,
verificationToken = integrityRequestManager.requestToken().getOrThrow(),
appId = application.packageName,
sessionId = sessionId
)
)
} else {
requireNotNull(
consumerSessionRepository.postConsumerSession(
email = email.lowercase().trim(),
clientSecret = configuration.financialConnectionsSessionClientSecret
)
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.stripe.android.financialconnections.features.error

import com.stripe.android.core.exception.APIException
import com.stripe.attestation.AttestationError

internal val Throwable.isAttestationError: Boolean
get() = when (this) {
// Stripe backend could not verify the intregrity of the request
is APIException -> stripeError?.code == "link_failed_to_attest_request"
// Interaction with Integrity API to generate tokens resulted in a failure
is AttestationError -> true
else -> false
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ internal class NetworkingLinkLoginWarmupPreviewParameterProvider :
NetworkingLinkLoginWarmupState.Payload(
merchantName = "Test",
redactedEmail = "emai•••@test.com",
email = "email@test.com"
email = "email@test.com",
verifiedFlow = false,
sessionId = "sessionId"
)
),
disableNetworkingAsync = Uninitialized,
Expand All @@ -46,7 +48,9 @@ internal class NetworkingLinkLoginWarmupPreviewParameterProvider :
NetworkingLinkLoginWarmupState.Payload(
merchantName = "Test",
redactedEmail = "emai•••@test.com",
email = "email@test.com"
email = "email@test.com",
verifiedFlow = false,
sessionId = "sessionId"
)
),
disableNetworkingAsync = Fail(Exception("Error")),
Expand All @@ -58,7 +62,9 @@ internal class NetworkingLinkLoginWarmupPreviewParameterProvider :
NetworkingLinkLoginWarmupState.Payload(
merchantName = "Test",
redactedEmail = "emai•••@test.com",
email = "email@test.com"
email = "email@test.com",
verifiedFlow = false,
sessionId = "sessionId"
)
),
disableNetworkingAsync = Loading(),
Expand All @@ -70,7 +76,9 @@ internal class NetworkingLinkLoginWarmupPreviewParameterProvider :
NetworkingLinkLoginWarmupState.Payload(
merchantName = "Test",
redactedEmail = "emai•••@test.com",
email = "email@test.com"
email = "email@test.com",
verifiedFlow = false,
sessionId = "sessionId"
)
),
disableNetworkingAsync = Uninitialized,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.stripe.android.financialconnections.presentation.Async
import com.stripe.android.financialconnections.presentation.Async.Uninitialized
import com.stripe.android.financialconnections.presentation.FinancialConnectionsSheetNativeState
import com.stripe.android.financialconnections.presentation.FinancialConnectionsViewModel
import com.stripe.android.model.EmailSource
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
Expand All @@ -51,7 +52,9 @@ internal class NetworkingLinkLoginWarmupViewModel @AssistedInject constructor(
NetworkingLinkLoginWarmupState.Payload(
merchantName = manifest.getBusinessName(),
redactedEmail = requireNotNull(manifest.getRedactedEmail()),
email = requireNotNull(manifest.accountholderCustomerEmailAddress)
email = requireNotNull(manifest.accountholderCustomerEmailAddress),
sessionId = manifest.id,
verifiedFlow = manifest.appVerificationEnabled
)
}.execute { copy(payload = it) }
}
Expand Down Expand Up @@ -91,7 +94,12 @@ internal class NetworkingLinkLoginWarmupViewModel @AssistedInject constructor(
suspend {
eventTracker.track(Click("click.continue", PANE))
// Trigger a lookup call to ensure we cache a consumer session for posterior verification.
lookupAccount(payload.email)
lookupAccount(
email = payload.email,
emailSource = EmailSource.CUSTOMER_OBJECT,
sessionId = payload.sessionId,
verifiedFlow = payload.verifiedFlow
)
navigationManager.tryNavigateTo(Destination.NetworkingLinkVerification(referrer = PANE))
}.execute {
copy(continueAsync = it)
Expand Down Expand Up @@ -193,6 +201,8 @@ internal data class NetworkingLinkLoginWarmupState(
data class Payload(
val merchantName: String?,
val email: String,
val redactedEmail: String
val redactedEmail: String,
val verifiedFlow: Boolean,
val sessionId: String
)
}
Loading

0 comments on commit 96e9ba3

Please sign in to comment.