Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ abstract class ExploreDashModule {

@Provides
fun provideCTXAuthApi(remoteDataSource: RemoteDataSource): CTXSpendTokenApi {
return remoteDataSource.buildApi(CTXSpendTokenApi::class.java)
return remoteDataSource.buildTokenApi()
}

@Provides
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ class RemoteDataSource @Inject constructor(
.create(api)
}

private fun buildTokenApi(): CTXSpendTokenApi {
fun buildTokenApi(): CTXSpendTokenApi {
return Retrofit.Builder()
.baseUrl(
if (walletData.networkParameters.id == NetworkParameters.ID_MAINNET) {
Expand All @@ -67,15 +67,18 @@ class RemoteDataSource @Inject constructor(
CTXSpendConstants.DEV_BASE_URL
}
)
.client(getOkHttpClient())
.client(getOkHttpClient(includeAuthorization = false))
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(CTXSpendTokenApi::class.java)
}

private fun getOkHttpClient(authenticator: Authenticator? = null): OkHttpClient {
private fun getOkHttpClient(
authenticator: Authenticator? = null,
includeAuthorization: Boolean = true
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(HeadersInterceptor(config))
.addInterceptor(HeadersInterceptor(config, includeAuthorization))
.addInterceptor(ErrorHandlingInterceptor(ServiceName.CTXSpend))
.connectTimeout(20.seconds.toJavaDuration())
.callTimeout(20.seconds.toJavaDuration())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
*/
package org.dash.wallet.features.exploredash.network.authenticator

import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
Expand All @@ -27,46 +29,100 @@ import org.dash.wallet.features.exploredash.data.dashspend.ctx.model.RefreshToke
import org.dash.wallet.features.exploredash.data.dashspend.ctx.model.RefreshTokenResponse
import org.dash.wallet.features.exploredash.network.service.ctxspend.CTXSpendTokenApi
import org.dash.wallet.features.exploredash.utils.CTXSpendConfig
import retrofit2.HttpException
import javax.inject.Inject

class TokenAuthenticator @Inject constructor(
private val tokenApi: CTXSpendTokenApi,
private val config: CTXSpendConfig
) : Authenticator {

// For multiple call to refresh token sync
private val tokenMutex = Mutex()
companion object {
// Shared across every TokenAuthenticator instance (the OkHttp retry path plus the
// DI/factory-built instances) so concurrent refreshes are serialized against the
// single shared token store. Without this, a refresh that loses the race could
// receive a 401 for an already-rotated refresh token and clear tokens that another
// caller just saved.
private val tokenMutex = Mutex()
}

override fun authenticate(route: Route?, response: Response): Request? {
if (response.responseCount >= 2) {
return null
}

return runBlocking {
tokenMutex.withLock {
try {
val tokenResponse = getUpdatedToken()
tokenResponse?.let {
config.setSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN, it.accessToken ?: "")
config.setSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN, it.refreshToken ?: "")
if (it.refreshToken != null) {
config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, System.currentTimeMillis())
} else {
config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, 0L)
}
response.request.newBuilder()
.header("Authorization", "Bearer ${it.accessToken}")
.build()
}
} catch (e: Exception) {
config.setSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN, "")
config.setSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN, "")
config.set(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME, 0L)
config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, 0L)
null
}
refreshAccessToken(response.request.accessToken)
?.let { response.request.withAccessToken(it) }
}
}

/**
* Refreshes the access token under a process-wide lock and persists the result, so the
* OkHttp retry path and explicit [CTXSpendRepository.refreshToken] calls never issue
* overlapping refreshes.
*
* @param staleAccessToken the rejected access token, when known (the OkHttp retry path).
* If another caller already rotated the token, the freshly stored token is returned
* without a network call. Pass null to force a refresh.
* @return the valid access token, or null when the refresh token was rejected (state is
* cleared) or the refresh failed transiently (state is preserved).
*/
suspend fun refreshAccessToken(staleAccessToken: String? = null): String? = tokenMutex.withLock {
val currentAccessToken = config.getSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN)

if (staleAccessToken != null && !currentAccessToken.isNullOrBlank() && currentAccessToken != staleAccessToken) {
return@withLock currentAccessToken
}

try {
withContext(NonCancellable) {
val tokenResponse = getUpdatedToken()
val accessToken = tokenResponse?.accessToken?.takeIf { it.isNotBlank() }
?: return@withContext null

config.saveTokenState(accessToken, tokenResponse.refreshToken)
accessToken
}
} catch (e: Exception) {
// Only a genuine refresh-token rejection drops the session. The refresh endpoint
// returns HTTP 401 for that case, which ErrorHandlingInterceptor's /refresh-token
// passthrough surfaces as an HttpException; any other failure is treated as
// transient and the tokens are kept so the user stays signed in.
if (e.isRefreshTokenRejected()) {
config.clearTokenState()
}
null
}
}

suspend fun getUpdatedToken(): RefreshTokenResponse? {
private suspend fun getUpdatedToken(): RefreshTokenResponse? {
val refreshToken = config.getSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN) ?: ""
return tokenApi.refreshToken(RefreshTokenRequest(refreshToken = refreshToken))
}

private val Response.responseCount: Int
get() {
var result = 1
var priorResponse = this.priorResponse
while (priorResponse != null) {
result++
priorResponse = priorResponse.priorResponse
}
return result
}

private val Request.accessToken: String?
get() = header("Authorization")
?.takeIf { it.startsWith("Bearer ") }
?.removePrefix("Bearer ")
?.takeIf { it.isNotBlank() }

private fun Request.withAccessToken(accessToken: String): Request =
newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()

private fun Exception.isRefreshTokenRejected(): Boolean =
this is HttpException && code() == 401
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,23 @@ import org.dash.wallet.features.exploredash.utils.CTXSpendConfig
import org.dash.wallet.features.exploredash.utils.CTXSpendConstants
import javax.inject.Inject

class HeadersInterceptor @Inject constructor(private val config: CTXSpendConfig) : Interceptor {
class HeadersInterceptor @Inject constructor(
private val config: CTXSpendConfig,
private val includeAuthorization: Boolean = true
) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val requestBuilder = original.newBuilder()
requestBuilder.header("Accept", "application/json")
requestBuilder.header(CTXSpendConstants.CLIENT_ID_PARAM_NAME, CTXSpendConstants.CLIENT_ID)

val accessToken = runBlocking {
config.getSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN)
val accessToken = if (includeAuthorization) {
runBlocking {
config.getSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN)
}
} else {
null
}

if (accessToken?.isNotEmpty() == true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,14 @@ class CTXSpendRepository @Inject constructor(
override suspend fun verifyEmail(code: String): Boolean {
val email = config.getSecuredData(CTXSpendConfig.PREFS_KEY_CTX_PAY_EMAIL)
val response = api.verifyEmail(VerifyEmailRequest(email = email!!, code = code))
config.setSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN, response?.accessToken!!)
config.setSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN, response.refreshToken!!)
val time = System.currentTimeMillis()
config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, time)
config.set(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME, time)
val accessToken = response?.accessToken
val refreshToken = response?.refreshToken

if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) {
return false
}

config.saveTokenState(accessToken, refreshToken)
return config.getSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN)?.isNotEmpty() ?: false
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand All @@ -138,10 +141,7 @@ class CTXSpendRepository @Inject constructor(
}

suspend fun reset() {
config.setSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN, "")
config.setSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN, "")
config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, 0L)
config.set(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME, 0L)
config.clearTokenState()
}

override suspend fun purchaseGiftCard(
Expand Down Expand Up @@ -237,10 +237,17 @@ class CTXSpendRepository @Inject constructor(

suspend fun checkToken(): Boolean {
val refreshTokenTime = config.get(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME)
return if (refreshTokenTime == null) {
val refreshToken = config.getSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN)

return if (refreshToken.isNullOrBlank()) {
reset()
false
} else if (refreshTokenTime == null) {
refreshToken()
} else if ((System.currentTimeMillis() - refreshTokenTime) < REFRESH_TOKEN_EXPIRATION) {
true
} else {
(System.currentTimeMillis() - refreshTokenTime) < REFRESH_TOKEN_EXPIRATION
refreshToken()
}
}

Expand All @@ -252,21 +259,10 @@ class CTXSpendRepository @Inject constructor(
}

override suspend fun refreshToken(): Boolean {
return try {
val tokenResponse = tokenAuthenticator.getUpdatedToken()
tokenResponse?.let {
config.setSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN, it.accessToken ?: "")
config.setSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN, it.refreshToken ?: "")
config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, System.currentTimeMillis())
true
} ?: false
} catch (e: Exception) {
config.setSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN, "")
config.setSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN, "")
config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, 0L)
config.set(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME, 0L)
false
}
// Delegate to the authenticator so this shares the same process-wide lock and token
// persistence as the OkHttp retry path, avoiding overlapping refreshes that could
// clear a token another caller just rotated.
return tokenAuthenticator.refreshAccessToken() != null
}

suspend fun getCTXSpendEmail(): String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import org.dash.wallet.features.exploredash.network.PiggyCardsRemoteDataSource
import org.dash.wallet.features.exploredash.network.RemoteDataSource
import org.dash.wallet.features.exploredash.network.authenticator.TokenAuthenticator
import org.dash.wallet.features.exploredash.network.service.ctxspend.CTXSpendApi
import org.dash.wallet.features.exploredash.network.service.ctxspend.CTXSpendTokenApi
import org.dash.wallet.features.exploredash.network.service.piggycards.PiggyCardsApi
import org.dash.wallet.features.exploredash.utils.CTXSpendConfig
import org.dash.wallet.features.exploredash.utils.PiggyCardsConfig
Expand All @@ -46,7 +45,7 @@ class DashSpendRepositoryFactory @Inject constructor(
private fun createCTXSpend(): CTXSpendRepository {
val remoteDataSource = RemoteDataSource(ctxSpendConfig, walletDataProvider)
val api = remoteDataSource.buildApi(CTXSpendApi::class.java)
val tokenApi = remoteDataSource.buildApi(CTXSpendTokenApi::class.java)
val tokenApi = remoteDataSource.buildTokenApi()
val tokenAuthenticator = TokenAuthenticator(tokenApi, ctxSpendConfig)

return CTXSpendRepository(api, ctxSpendConfig, tokenAuthenticator)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import org.dash.wallet.common.util.safeNavigate
import org.dash.wallet.features.exploredash.R
import org.dash.wallet.features.exploredash.data.dashspend.GiftCardProviderType
import org.dash.wallet.features.exploredash.databinding.FragmentDashSpendUserAuthBinding
import org.dash.wallet.features.exploredash.repository.CTXSpendException
import org.dash.wallet.features.exploredash.utils.exploreViewModels
import org.slf4j.LoggerFactory
import retrofit2.HttpException
Expand Down Expand Up @@ -240,25 +241,47 @@ class DashSpendUserAuthFragment : Fragment(R.layout.fragment_dash_spend_user_aut
if (success) {
viewModel.logEvent(AnalyticsConstants.DashSpend.SUCCESSFUL_LOGIN)
hideKeyboard()
when (viewModel.selectedProvider) {
// Navigate based on the provider from nav args, not viewModel.selectedProvider:
// selectedProvider is non-persisted nav-graph ViewModel state that resets to null
// when the ViewModel is recreated (e.g. process death while the user leaves the app
// to read the emailed code). That previously crashed this success path and was
// mis-reported as an invalid code. Restore it so downstream screens still have it.
viewModel.selectedProvider = provider
when (provider) {
GiftCardProviderType.CTX -> safeNavigate(
DashSpendUserAuthFragmentDirections.authToPurchaseGiftCardFragment()
)
GiftCardProviderType.PiggyCards -> safeNavigate(
DashSpendUserAuthFragmentDirections.authToPurchaseGiftCardFragmentV2()
)
else -> error("serious error. provider = null")
}
} else {
// A non-exception failure means the code was accepted but the backend returned
// no usable tokens - a server anomaly, not a wrong code.
viewModel.logEvent(AnalyticsConstants.DashSpend.UNSUCCESSFUL_LOGIN)
showVerifyError(getString(R.string.loading_error))
}
} catch (e: Exception) {
binding.inputWrapper.isErrorEnabled = true
binding.inputErrorTv.text = getString(R.string.invaild_code)
binding.inputErrorTv.isVisible = true
log.error(
"DashSpend: verify code failed for ${provider.name}: ${e::class.simpleName} - ${e.message}",
e
)
viewModel.logEvent(AnalyticsConstants.DashSpend.UNSUCCESSFUL_LOGIN)
// Only a genuine rejection of the entered code (HTTP 400) is the user's fault;
// transient/server/transport errors get a generic message instead of "wrong code".
val isInvalidCode = e is CTXSpendException && e.errorCode == 400
showVerifyError(getString(if (isInvalidCode) R.string.invaild_code else R.string.loading_error))
}
hideLoading()
}
}

private fun showVerifyError(message: String) {
binding.inputWrapper.isErrorEnabled = true
binding.inputErrorTv.text = message
binding.inputErrorTv.isVisible = true
}

private fun isEmail(text: CharSequence?): Boolean {
return !text.isNullOrEmpty() && Patterns.EMAIL_ADDRESS.matcher(text).matches()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ package org.dash.wallet.features.exploredash.utils
import android.content.Context
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import org.dash.wallet.common.WalletDataProvider
import org.dash.wallet.common.data.BaseConfig
import org.dash.wallet.common.util.security.EncryptionProvider
Expand All @@ -43,4 +45,25 @@ class CTXSpendConfig @Inject constructor(
val PREFS_DEVICE_UUID = stringPreferencesKey("device_uuid")
val PREFS_LAST_PURCHASE_START = longPreferencesKey("last_purchase_start")
}

suspend fun saveTokenState(
accessToken: String,
refreshToken: String? = null,
now: Long = System.currentTimeMillis()
) = withContext(NonCancellable) {
setSecuredData(PREFS_KEY_ACCESS_TOKEN, accessToken)
set(PREFS_KEY_ACCESS_TOKEN_TIME, now)

if (!refreshToken.isNullOrBlank()) {
setSecuredData(PREFS_KEY_REFRESH_TOKEN, refreshToken)
set(PREFS_KEY_REFRESH_TOKEN_TIME, now)
}
}

suspend fun clearTokenState() = withContext(NonCancellable) {
setSecuredData(PREFS_KEY_ACCESS_TOKEN, "")
setSecuredData(PREFS_KEY_REFRESH_TOKEN, "")
set(PREFS_KEY_ACCESS_TOKEN_TIME, 0L)
set(PREFS_KEY_REFRESH_TOKEN_TIME, 0L)
}
}
Loading